Skip to content

Commit eccce29

Browse files
Refactor skill role stuff
1 parent cb4aabc commit eccce29

9 files changed

Lines changed: 246 additions & 255 deletions

File tree

src/components/anti-invite-links.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,15 @@ export default class AntiInviteLinks extends BotComponent {
2626
return true;
2727
}
2828

29-
async member_is_proficient_or_higher(member: Discord.GuildMember | null) {
30-
if (!member) {
31-
return false;
32-
}
33-
const skill_roles = member.roles.cache.filter(role =>
34-
Object.values(this.wheatley.skill_roles).some(skill_role => role.id == skill_role.id),
35-
);
36-
if (skill_roles.size > 1) {
37-
const skill_role_ranks = Object.values(this.wheatley.skill_roles).map(role => role.id);
38-
const proficient_index = skill_role_ranks.indexOf(this.wheatley.skill_roles.proficient.id);
39-
assert(proficient_index !== -1);
40-
return skill_roles.some(role => skill_role_ranks.indexOf(role.id) >= proficient_index);
41-
}
42-
return false;
43-
}
44-
4529
async handle_message(message: Discord.Message) {
4630
if (this.wheatley.is_authorized_mod(message.author)) {
4731
return;
4832
}
4933
const match = match_invite(message.content);
50-
if (match && !whitelist.includes(match) && !(await this.member_is_proficient_or_higher(message.member))) {
34+
if (match && !whitelist.includes(match)) {
35+
if (message.member && this.wheatley.is_established_member(message.member)) {
36+
return false;
37+
}
5138
const quote = await this.utilities.make_quote_embeds([message]);
5239
await message.delete();
5340
assert(!(message.channel instanceof Discord.PartialGroupDMChannel));

src/components/moderation/modmail.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,8 @@ export default class Modmail extends BotComponent {
225225
await this.log_action(interaction.member, "Modmail button spammed");
226226
} else {
227227
const member = await this.wheatley.TCCPP.members.fetch(interaction.user.id);
228-
const non_beginner_skill_roles = member.roles.cache.filter(role =>
229-
Object.values(this.wheatley.skill_roles).some(
230-
skill_role => role.id == skill_role.id && skill_role.name != "Beginner",
231-
),
232-
);
233-
if (non_beginner_skill_roles.size > 0) {
234-
// fast-path people who can read
228+
if (this.wheatley.is_established_member(member)) {
229+
// fast-path established members
235230
await interaction.deferReply({
236231
ephemeral: true,
237232
});

src/components/role-manager.ts

Lines changed: 48 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,38 @@ import { colors, HOUR, MINUTE } from "../common.js";
44
import { unwrap } from "../utils/misc.js";
55
import { M } from "../utils/debugging-and-logging.js";
66
import { BotComponent } from "../bot-component.js";
7-
import { CommandSetBuilder } from "../command-abstractions/command-set-builder.js";
8-
import { EarlyReplyMode, TextBasedCommandBuilder } from "../command-abstractions/text-based-command-builder.js";
9-
import { TextBasedCommand } from "../command-abstractions/text-based-command.js";
10-
import { skill_roles_order, skill_roles_order_id, Wheatley } from "../wheatley.js";
117
import { set_interval } from "../utils/node.js";
12-
import { equal } from "../utils/arrays.js";
138
import { build_description } from "../utils/strings.js";
14-
import { SelfClearingSet } from "../utils/containers.js";
159

16-
// Role cleanup
17-
// Auto-remove pink roles when members are no longer boosting
18-
// Auto-remove duplicate skill roles
19-
20-
type user_role_entry = {
10+
export type user_role_entry = {
2111
user_id: string;
2212
roles: string[];
23-
last_known_skill_role: string | null;
2413
};
2514

15+
type role_check = (member: Discord.GuildMember) => Promise<void>;
16+
type role_change_listener = (role_id: string, member: Discord.GuildMember) => Promise<void>;
17+
2618
export default class RoleManager extends BotComponent {
27-
pink_role: Discord.Role;
28-
interval: NodeJS.Timeout | null = null;
19+
private interval: NodeJS.Timeout | null = null;
2920

3021
// current database state
31-
roles = new Map<string, string[]>();
32-
33-
// stores user id + role id encoded as a user_id,role_id string
34-
debounce_map = new SelfClearingSet(MINUTE);
22+
private roles = new Map<string, Set<string>>();
3523

3624
// roles that will not be re-applied on join
37-
blacklisted_roles: Set<string>;
25+
private blacklisted_roles: Set<string>;
26+
27+
private role_checks: role_check[] = [];
28+
private on_role_changed = new Map<string, role_change_listener[]>();
3829

3930
private database = unwrap(this.wheatley.database).create_proxy<{
4031
user_roles: user_role_entry;
4132
}>();
4233

43-
override async setup(commands: CommandSetBuilder) {
44-
commands.add(
45-
new TextBasedCommandBuilder("gimmepink", EarlyReplyMode.ephemeral)
46-
.set_description("Gives pink")
47-
.set_slash(false)
48-
.set_handler(this.gibpink.bind(this)),
49-
);
50-
51-
commands.add(
52-
new TextBasedCommandBuilder("unpink", EarlyReplyMode.ephemeral)
53-
.set_description("Takes pink")
54-
.set_slash(false)
55-
.set_handler(this.unpink.bind(this)),
56-
);
57-
}
58-
5934
override async on_ready() {
35+
for await (const entry of this.database.user_roles.find()) {
36+
this.roles.set(entry.user_id, new Set(entry.roles));
37+
}
38+
6039
this.blacklisted_roles = new Set([
6140
// general
6241
this.wheatley.roles.root.id,
@@ -83,132 +62,53 @@ export default class RoleManager extends BotComponent {
8362
this.wheatley.roles.herald.id,
8463
this.wheatley.roles.linked_github.id,
8564
]);
86-
this.pink_role = unwrap(await this.wheatley.TCCPP.roles.fetch(this.wheatley.roles.pink.id));
87-
this.interval = set_interval(() => {
88-
this.check_members().catch(this.wheatley.critical_error.bind(this.wheatley));
89-
}, HOUR);
90-
this.startup_recovery().catch(this.wheatley.critical_error.bind(this.wheatley));
91-
}
9265

93-
get_highest_skill_role(roles: string[]) {
94-
return skill_roles_order_id.filter(id => roles.includes(id)).at(-1) ?? null;
95-
}
96-
97-
async update_user_roles(member: Discord.GuildMember) {
98-
const old_roles = this.roles.get(member.id) ?? [];
99-
const current_roles = member.roles.cache.map(role => role.id);
100-
if (!equal(old_roles, current_roles)) {
101-
const skill_role = this.get_highest_skill_role(current_roles);
102-
await this.database.user_roles.updateOne(
103-
{ user_id: member.id },
104-
{
105-
$set: skill_role
106-
? {
107-
roles: current_roles,
108-
last_known_skill_role: this.get_highest_skill_role(current_roles),
109-
}
110-
: {
111-
roles: current_roles,
112-
},
113-
},
114-
{ upsert: true },
115-
);
116-
}
117-
}
118-
119-
async gibpink(command: TextBasedCommand) {
120-
const member = await command.get_member(this.wheatley.TCCPP);
121-
if (member.premiumSince == null) {
122-
await command.reply("Nice try.", true, true);
123-
return;
124-
}
125-
if (member.roles.cache.some(r => r.id == this.pink_role.id)) {
126-
await command.reply("You are currently pink", true, true);
127-
return;
128-
}
129-
await member.roles.add(this.pink_role);
66+
const check = () => {
67+
this.check_members().catch(this.wheatley.critical_error.bind(this.wheatley));
68+
};
69+
check();
70+
this.interval = set_interval(check, HOUR);
13071
}
13172

132-
async unpink(command: TextBasedCommand) {
133-
const member = await command.get_member(this.wheatley.TCCPP);
134-
if (!member.roles.cache.some(r => r.id == this.pink_role.id)) {
135-
await command.reply("You are not currently pink", true, true);
136-
return;
137-
}
138-
await member.roles.remove(this.pink_role);
73+
register_role_check(check: role_check) {
74+
this.role_checks.push(check);
13975
}
14076

141-
async handle_pink(member: Discord.GuildMember) {
142-
if (member.roles.cache.some(role => role.id == this.wheatley.roles.pink.id)) {
143-
if (member.premiumSince == null) {
144-
M.log("removing pink for", member.user.tag);
145-
await member.roles.remove(this.pink_role);
146-
}
77+
register_role_update_listener(role_id: string, listener: role_change_listener) {
78+
if (!this.on_role_changed.has(role_id)) {
79+
this.on_role_changed.set(role_id, []);
14780
}
81+
unwrap(this.on_role_changed.get(role_id)).push(listener);
14882
}
14983

150-
async handle_skill_roles(member: Discord.GuildMember) {
151-
const skill_roles = member.roles.cache.filter(role =>
152-
Object.values(this.wheatley.skill_roles).some(skill_role => role.id == skill_role.id),
153-
);
154-
if (skill_roles.size > 1) {
155-
M.log("removing duplicate skill roles for", member.user.tag);
156-
skill_roles.sort((a, b) => b.rawPosition - a.rawPosition);
157-
M.debug(skill_roles.map(x => x.name));
158-
M.debug(skill_roles.map(x => x.name).slice(1));
159-
for (const role of skill_roles.map(x => x).slice(1)) {
160-
await member.roles.remove(role);
161-
}
84+
async check_member_roles(member: Discord.GuildMember) {
85+
for (const check of this.role_checks) {
86+
await check(member);
16287
}
163-
}
164-
165-
async check_for_skill_role_bump(
166-
old_member: Discord.GuildMember | Discord.PartialGuildMember,
167-
new_member: Discord.GuildMember,
168-
) {
169-
const roles_entry = await this.database.user_roles.findOne({ user_id: new_member.id });
170-
const last_known_skill_level =
171-
roles_entry && roles_entry.last_known_skill_role
172-
? this.wheatley.get_skill_role_index(roles_entry.last_known_skill_role)
173-
: -1;
174-
const current_skill_role = this.get_highest_skill_role(new_member.roles.cache.map(role => role.id));
175-
const current_skill_level = current_skill_role ? this.wheatley.get_skill_role_index(current_skill_role) : -1;
176-
if (
177-
current_skill_level > skill_roles_order.indexOf("beginner") &&
178-
current_skill_level > last_known_skill_level
179-
) {
180-
assert(current_skill_role);
181-
const debounce_key = `${new_member.id},${current_skill_role}`;
182-
// Updates are bumpy...
183-
if (this.debounce_map.has(debounce_key)) {
184-
return;
88+
const old_roles = this.roles.get(member.id);
89+
const current_roles = member.roles.cache;
90+
const diff = old_roles?.symmetricDifference(current_roles);
91+
if (diff === undefined || diff.size > 0) {
92+
const role_ids = current_roles.map(role => role.id);
93+
await this.database.user_roles.findOneAndUpdate(
94+
{ user_id: member.id },
95+
{ $set: { roles: role_ids } },
96+
{ upsert: true },
97+
);
98+
const new_roles = new Set(role_ids);
99+
this.roles.set(member.id, new_roles);
100+
const events = (diff ?? new_roles).intersection(this.on_role_changed);
101+
for (const id of events) {
102+
const listeners = this.on_role_changed.get(id);
103+
if (listeners) {
104+
for (const listener of listeners) {
105+
await listener(id, member);
106+
}
107+
}
185108
}
186-
this.debounce_map.insert(debounce_key);
187-
M.log("Detected skill level increase for", new_member.user.tag);
188-
await this.wheatley.channels.skill_role_log.send({
189-
embeds: [
190-
new Discord.EmbedBuilder()
191-
.setAuthor({
192-
name: new_member.displayName,
193-
iconURL: new_member.displayAvatarURL(),
194-
})
195-
.setColor(unwrap(await this.wheatley.TCCPP.roles.fetch(current_skill_role)).color)
196-
.setDescription(
197-
roles_entry?.last_known_skill_role
198-
? `<@&${roles_entry.last_known_skill_role}> -> <@&${current_skill_role}>`
199-
: `<@&${current_skill_role}>`,
200-
),
201-
],
202-
});
203109
}
204110
}
205111

206-
async check_member_roles(member: Discord.GuildMember) {
207-
await this.handle_pink(member);
208-
await this.handle_skill_roles(member);
209-
await this.update_user_roles(member);
210-
}
211-
212112
async check_members() {
213113
M.log("Starting role checks");
214114
try {
@@ -222,19 +122,10 @@ export default class RoleManager extends BotComponent {
222122
M.log("Finished role checks");
223123
}
224124

225-
async startup_recovery() {
226-
const entries = await this.database.user_roles.find().toArray();
227-
for (const entry of entries) {
228-
this.roles.set(entry.user_id, entry.roles);
229-
}
230-
await this.check_members();
231-
}
232-
233125
override async on_guild_member_update(
234126
old_member: Discord.GuildMember | Discord.PartialGuildMember,
235127
new_member: Discord.GuildMember,
236128
) {
237-
await this.check_for_skill_role_bump(old_member, new_member);
238129
await this.check_member_roles(new_member);
239130
}
240131

src/modules/tccpp/components/anti-screenshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ export default class AntiScreenshot extends BotComponent {
8787
await delay(1000);
8888
assert(starter_message);
8989
assert(starter_message.member);
90-
// trust people with skill roles
91-
if (this.wheatley.has_skill_roles_other_than_beginner(starter_message.member)) {
90+
// trust established members
91+
if (this.wheatley.is_established_member(starter_message.member)) {
9292
return;
9393
}
9494
// check if it has images and no code

src/modules/tccpp/components/formatting-error-detection.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { strict as assert } from "assert";
55
import { M } from "../../../utils/debugging-and-logging.js";
66
import { colors, MINUTE } from "../../../common.js";
77
import { BotComponent } from "../../../bot-component.js";
8+
import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js";
9+
import SkillRoles from "./skill-roles.js";
810
import { build_description, parse_out } from "../../../utils/strings.js";
911
import Code from "../../../components/code.js";
1012
import { SelfClearingMap, SelfClearingSet } from "../../../utils/containers.js";
@@ -121,10 +123,8 @@ export default class FormattingErrorDetection extends BotComponent {
121123
}
122124

123125
has_likely_format_errors(message: Discord.Message) {
124-
const has_skill_roles_other_than_beginner = message.member
125-
? this.wheatley.has_skill_roles_other_than_beginner(message.member)
126-
: false;
127-
if (has_skill_roles_other_than_beginner) {
126+
// trust Proficient+ members
127+
if (message.member && SkillRoles.find_highest_skill_role_index(message.member.roles.cache) >= 2) {
128128
return false;
129129
}
130130
return FormattingErrorDetection.has_likely_format_errors(message.content);

0 commit comments

Comments
 (0)