Skip to content

Commit 23ce379

Browse files
Introduce role check and role change callbacks (#178)
Prerequisite for #148 and #179, factored out for easier review: * introduce callbacks for other components to register role checks and be notified of role updates * recast role change detection logic into the language of sets * role update events only fire once new state has been committed, no need for debounce map anymore --------- Co-authored-by: Jeremy Rifkin <51220084+jeremy-rifkin@users.noreply.github.com>
1 parent 66f7b52 commit 23ce379

1 file changed

Lines changed: 59 additions & 57 deletions

File tree

src/components/role-manager.ts

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ import { EarlyReplyMode, TextBasedCommandBuilder } from "../command-abstractions
99
import { TextBasedCommand } from "../command-abstractions/text-based-command.js";
1010
import { skill_roles_order, skill_roles_order_id, Wheatley } from "../wheatley.js";
1111
import { set_interval } from "../utils/node.js";
12-
import { equal } from "../utils/arrays.js";
1312
import { build_description } from "../utils/strings.js";
14-
import { SelfClearingSet } from "../utils/containers.js";
1513

1614
// Role cleanup
1715
// Auto-remove pink roles when members are no longer boosting
@@ -23,17 +21,21 @@ type user_role_entry = {
2321
last_known_skill_role: string | null;
2422
};
2523

24+
type role_check = (member: Discord.GuildMember) => Promise<void>;
25+
type role_update_callback = (member: Discord.GuildMember) => Promise<void>;
26+
type role_update_listener = {
27+
roles_of_interest: Set<string>;
28+
callback: role_update_callback;
29+
};
30+
2631
export default class RoleManager extends BotComponent {
2732
private skill_role_log!: Discord.TextChannel;
2833
private staff_member_log!: Discord.TextChannel;
2934
pink_role!: Discord.Role;
3035
interval: NodeJS.Timeout | null = null;
3136

3237
// current database state
33-
roles = new Map<string, string[]>();
34-
35-
// stores user id + role id encoded as a user_id,role_id string
36-
debounce_map = new SelfClearingSet(MINUTE);
38+
private roles = new Map<string, Set<string>>();
3739

3840
// roles that will not be re-applied on join
3941
blacklisted_roles!: Set<string>;
@@ -42,6 +44,17 @@ export default class RoleManager extends BotComponent {
4244
user_roles: user_role_entry;
4345
}>();
4446

47+
private role_checks: role_check[] = [];
48+
private role_update_listeners: role_update_listener[] = [];
49+
50+
register_role_check(check: role_check) {
51+
this.role_checks.push(check);
52+
}
53+
54+
register_role_update_listener(roles_of_interest: Set<string>, callback: role_update_callback) {
55+
this.role_update_listeners.push({ roles_of_interest: roles_of_interest, callback: callback });
56+
}
57+
4558
override async setup(commands: CommandSetBuilder) {
4659
this.skill_role_log = await this.utilities.get_channel(this.wheatley.channels.skill_role_log);
4760
this.staff_member_log = await this.utilities.get_channel(this.wheatley.channels.staff_member_log);
@@ -64,6 +77,10 @@ export default class RoleManager extends BotComponent {
6477
}
6578

6679
override async on_ready() {
80+
for await (const entry of this.database.user_roles.find()) {
81+
this.roles.set(entry.user_id, new Set(entry.roles));
82+
}
83+
6784
this.blacklisted_roles = new Set([
6885
// general
6986
this.wheatley.roles.root.id,
@@ -91,35 +108,44 @@ export default class RoleManager extends BotComponent {
91108
this.wheatley.roles.linked_github.id,
92109
]);
93110
this.pink_role = unwrap(await this.wheatley.guild.roles.fetch(this.wheatley.roles.pink.id));
94-
this.interval = set_interval(() => {
111+
112+
this.register_role_check(this.check_pink.bind(this));
113+
this.register_role_check(this.check_skill_roles.bind(this));
114+
this.register_role_update_listener(new Set(skill_roles_order_id), this.check_for_skill_role_bump.bind(this));
115+
116+
const check = () => {
95117
this.check_members().catch(this.wheatley.critical_error.bind(this.wheatley));
96-
}, HOUR);
97-
this.startup_recovery().catch(this.wheatley.critical_error.bind(this.wheatley));
118+
};
119+
check();
120+
this.interval = set_interval(check, HOUR);
98121
}
99122

100123
get_highest_skill_role(roles: string[]) {
101124
return skill_roles_order_id.filter(id => roles.includes(id)).at(-1) ?? null;
102125
}
103126

104-
async update_user_roles(member: Discord.GuildMember) {
105-
const old_roles = this.roles.get(member.id) ?? [];
106-
const current_roles = member.roles.cache.map(role => role.id);
107-
if (!equal(old_roles, current_roles)) {
108-
const skill_role = this.get_highest_skill_role(current_roles);
109-
await this.database.user_roles.updateOne(
127+
async check_member_roles(member: Discord.GuildMember) {
128+
for (const check of this.role_checks) {
129+
await check(member);
130+
}
131+
const old_roles = this.roles.get(member.id);
132+
const current_roles = member.roles.cache;
133+
const diff = old_roles?.symmetricDifference(current_roles);
134+
if (diff === undefined || diff.size > 0) {
135+
const role_ids = current_roles.map(role => role.id);
136+
const skill_role = this.get_highest_skill_role(member.roles.cache.map(role => role.id));
137+
await this.database.user_roles.findOneAndUpdate(
110138
{ user_id: member.id },
111-
{
112-
$set: skill_role
113-
? {
114-
roles: current_roles,
115-
last_known_skill_role: this.get_highest_skill_role(current_roles),
116-
}
117-
: {
118-
roles: current_roles,
119-
},
120-
},
139+
{ $set: skill_role ? { roles: role_ids, last_known_skill_role: skill_role } : { roles: role_ids } },
121140
{ upsert: true },
122141
);
142+
const new_roles = new Set(role_ids);
143+
this.roles.set(member.id, new_roles);
144+
for (const { roles_of_interest, callback } of this.role_update_listeners) {
145+
if (!roles_of_interest.isDisjointFrom(diff ?? new_roles)) {
146+
await callback(member);
147+
}
148+
}
123149
}
124150
}
125151

@@ -146,7 +172,7 @@ export default class RoleManager extends BotComponent {
146172
await member.roles.remove(this.pink_role);
147173
}
148174

149-
async handle_pink(member: Discord.GuildMember) {
175+
async check_pink(member: Discord.GuildMember) {
150176
if (member.roles.cache.some(role => role.id == this.wheatley.roles.pink.id)) {
151177
if (member.premiumSince == null) {
152178
M.log("removing pink for", member.user.tag);
@@ -155,7 +181,7 @@ export default class RoleManager extends BotComponent {
155181
}
156182
}
157183

158-
async handle_skill_roles(member: Discord.GuildMember) {
184+
async check_skill_roles(member: Discord.GuildMember) {
159185
const skill_roles = member.roles.cache.filter(role =>
160186
Object.values(this.wheatley.skill_roles).some(skill_role => role.id == skill_role.id),
161187
);
@@ -170,35 +196,26 @@ export default class RoleManager extends BotComponent {
170196
}
171197
}
172198

173-
async check_for_skill_role_bump(
174-
old_member: Discord.GuildMember | Discord.PartialGuildMember,
175-
new_member: Discord.GuildMember,
176-
) {
177-
const roles_entry = await this.database.user_roles.findOne({ user_id: new_member.id });
199+
async check_for_skill_role_bump(member: Discord.GuildMember) {
200+
const roles_entry = await this.database.user_roles.findOne({ user_id: member.id });
178201
const last_known_skill_level =
179202
roles_entry && roles_entry.last_known_skill_role
180203
? this.wheatley.get_skill_role_index(roles_entry.last_known_skill_role)
181204
: -1;
182-
const current_skill_role = this.get_highest_skill_role(new_member.roles.cache.map(role => role.id));
205+
const current_skill_role = this.get_highest_skill_role(member.roles.cache.map(role => role.id));
183206
const current_skill_level = current_skill_role ? this.wheatley.get_skill_role_index(current_skill_role) : -1;
184207
if (
185208
current_skill_level > skill_roles_order.indexOf("beginner") &&
186209
current_skill_level > last_known_skill_level
187210
) {
188211
assert(current_skill_role);
189-
const debounce_key = `${new_member.id},${current_skill_role}`;
190-
// Updates are bumpy...
191-
if (this.debounce_map.has(debounce_key)) {
192-
return;
193-
}
194-
this.debounce_map.insert(debounce_key);
195-
M.log("Detected skill level increase for", new_member.user.tag);
212+
M.log("Detected skill level increase for", member.user.tag);
196213
await this.skill_role_log.send({
197214
embeds: [
198215
new Discord.EmbedBuilder()
199216
.setAuthor({
200-
name: new_member.displayName,
201-
iconURL: new_member.displayAvatarURL(),
217+
name: member.displayName,
218+
iconURL: member.displayAvatarURL(),
202219
})
203220
.setColor(unwrap(await this.wheatley.guild.roles.fetch(current_skill_role)).color)
204221
.setDescription(
@@ -211,12 +228,6 @@ export default class RoleManager extends BotComponent {
211228
}
212229
}
213230

214-
async check_member_roles(member: Discord.GuildMember) {
215-
await this.handle_pink(member);
216-
await this.handle_skill_roles(member);
217-
await this.update_user_roles(member);
218-
}
219-
220231
async check_members() {
221232
M.log("Starting role checks");
222233
try {
@@ -230,19 +241,10 @@ export default class RoleManager extends BotComponent {
230241
M.log("Finished role checks");
231242
}
232243

233-
async startup_recovery() {
234-
const entries = await this.database.user_roles.find().toArray();
235-
for (const entry of entries) {
236-
this.roles.set(entry.user_id, entry.roles);
237-
}
238-
await this.check_members();
239-
}
240-
241244
override async on_guild_member_update(
242245
old_member: Discord.GuildMember | Discord.PartialGuildMember,
243246
new_member: Discord.GuildMember,
244247
) {
245-
await this.check_for_skill_role_bump(old_member, new_member);
246248
await this.check_member_roles(new_member);
247249
}
248250

0 commit comments

Comments
 (0)