From 8c32cdb255b1a58d5748cf1a33d1f255083c778c Mon Sep 17 00:00:00 2001 From: Michael Kenzel Date: Fri, 3 Oct 2025 00:58:32 +0200 Subject: [PATCH 1/7] Refactor skill role stuff --- src/components/anti-invite-links.ts | 18 +--- src/components/moderation/modmail.ts | 10 +- src/components/role-manager.ts | 63 +----------- .../tccpp/components/anti-screenshot.ts | 5 +- src/modules/tccpp/components/establishment.ts | 34 +++++++ .../components/formatting-error-detection.ts | 7 +- .../tccpp/components/permissions-manager.ts | 9 +- .../tccpp/components/skill-role-suggestion.ts | 51 +++++----- src/modules/tccpp/components/skill-roles.ts | 98 +++++++++++++++++++ src/wheatley.ts | 56 +++-------- 10 files changed, 185 insertions(+), 166 deletions(-) create mode 100644 src/modules/tccpp/components/establishment.ts create mode 100644 src/modules/tccpp/components/skill-roles.ts diff --git a/src/components/anti-invite-links.ts b/src/components/anti-invite-links.ts index 77292ce8..0fa602f4 100644 --- a/src/components/anti-invite-links.ts +++ b/src/components/anti-invite-links.ts @@ -203,28 +203,12 @@ export default class AntiInviteLinks extends BotComponent { } } - async member_is_proficient_or_higher(member: Discord.GuildMember | null) { - if (!member) { - return false; - } - const skill_roles = member.roles.cache.filter(role => - Object.values(this.wheatley.skill_roles).some(skill_role => role.id == skill_role.id), - ); - if (skill_roles.size > 1) { - const skill_role_ranks = Object.values(this.wheatley.skill_roles).map(role => role.id); - const proficient_index = skill_role_ranks.indexOf(this.wheatley.skill_roles.proficient.id); - assert(proficient_index !== -1); - return skill_roles.some(role => skill_role_ranks.indexOf(role.id) >= proficient_index); - } - return false; - } - async handle_message(message: Discord.Message) { if (await this.wheatley.check_permissions(message.author, Discord.PermissionFlagsBits.ModerateMembers)) { return; } const match = match_invite(message.content); - if (match && !(await this.is_allowed(match)) && !(await this.member_is_proficient_or_higher(message.member))) { + if (match && !(await this.is_allowed(match)) && !(await this.wheatley.is_established_member(message.author))) { const quote = await this.utilities.make_quote_embeds(message); await message.delete(); assert(!(message.channel instanceof Discord.PartialGroupDMChannel)); diff --git a/src/components/moderation/modmail.ts b/src/components/moderation/modmail.ts index d1830e08..f511fa00 100644 --- a/src/components/moderation/modmail.ts +++ b/src/components/moderation/modmail.ts @@ -280,14 +280,8 @@ export default class Modmail extends BotComponent { }); await this.log_action(interaction.member, "Modmail button spammed"); } else { - const member = await this.wheatley.guild.members.fetch(interaction.user.id); - const non_beginner_skill_roles = member.roles.cache.filter(role => - Object.values(this.wheatley.skill_roles).some( - skill_role => role.id == skill_role.id && skill_role.name != "Beginner", - ), - ); - if (non_beginner_skill_roles.size > 0) { - // fast-path people who can read + if (await this.wheatley.is_established_member(interaction.user)) { + // fast-path established members await interaction.deferReply({ ephemeral: true, }); diff --git a/src/components/role-manager.ts b/src/components/role-manager.ts index 713fc8a2..ebfc5541 100644 --- a/src/components/role-manager.ts +++ b/src/components/role-manager.ts @@ -5,14 +5,12 @@ import { unwrap } from "../utils/misc.js"; import { M } from "../utils/debugging-and-logging.js"; import { BotComponent } from "../bot-component.js"; import { CommandSetBuilder } from "../command-abstractions/command-set-builder.js"; -import { skill_roles_order, skill_roles_order_id, Wheatley } from "../wheatley.js"; import { set_interval } from "../utils/node.js"; import { build_description } from "../utils/strings.js"; -type user_role_entry = { +export type user_role_entry = { user_id: string; roles: string[]; - last_known_skill_role: string | null; }; type role_check = (member: Discord.GuildMember) => Promise; @@ -23,7 +21,6 @@ type role_update_listener = { }; export default class RoleManager extends BotComponent { - private skill_role_log!: Discord.TextChannel; private staff_member_log!: Discord.TextChannel; interval: NodeJS.Timeout | null = null; @@ -49,7 +46,6 @@ export default class RoleManager extends BotComponent { } override async setup(commands: CommandSetBuilder) { - this.skill_role_log = await this.utilities.get_channel(this.wheatley.channels.skill_role_log); this.staff_member_log = await this.utilities.get_channel(this.wheatley.channels.staff_member_log); } @@ -85,9 +81,6 @@ export default class RoleManager extends BotComponent { this.wheatley.roles.linked_github.id, ]); - this.register_role_check(this.check_skill_roles.bind(this)); - this.register_role_update_listener(new Set(skill_roles_order_id), this.check_for_skill_role_bump.bind(this)); - const check = () => { this.check_members().catch(this.wheatley.critical_error.bind(this.wheatley)); }; @@ -95,10 +88,6 @@ export default class RoleManager extends BotComponent { this.interval = set_interval(check, HOUR); } - get_highest_skill_role(roles: string[]) { - return skill_roles_order_id.filter(id => roles.includes(id)).at(-1) ?? null; - } - async check_member_roles(member: Discord.GuildMember) { for (const check of this.role_checks) { await check(member); @@ -108,10 +97,9 @@ export default class RoleManager extends BotComponent { const diff = old_roles?.symmetricDifference(current_roles); if (diff === undefined || diff.size > 0) { const role_ids = current_roles.map(role => role.id); - const skill_role = this.get_highest_skill_role(member.roles.cache.map(role => role.id)); await this.database.user_roles.findOneAndUpdate( { user_id: member.id }, - { $set: skill_role ? { roles: role_ids, last_known_skill_role: skill_role } : { roles: role_ids } }, + { $set: { roles: role_ids } }, { upsert: true }, ); const new_roles = new Set(role_ids); @@ -124,53 +112,6 @@ export default class RoleManager extends BotComponent { } } - async check_skill_roles(member: Discord.GuildMember) { - const skill_roles = member.roles.cache.filter(role => - Object.values(this.wheatley.skill_roles).some(skill_role => role.id == skill_role.id), - ); - if (skill_roles.size > 1) { - M.log("removing duplicate skill roles for", member.user.tag); - skill_roles.sort((a, b) => b.rawPosition - a.rawPosition); - M.debug(skill_roles.map(x => x.name)); - M.debug(skill_roles.map(x => x.name).slice(1)); - for (const role of skill_roles.map(x => x).slice(1)) { - await member.roles.remove(role); - } - } - } - - async check_for_skill_role_bump(member: Discord.GuildMember) { - const roles_entry = await this.database.user_roles.findOne({ user_id: member.id }); - const last_known_skill_level = - roles_entry && roles_entry.last_known_skill_role - ? this.wheatley.get_skill_role_index(roles_entry.last_known_skill_role) - : -1; - const current_skill_role = this.get_highest_skill_role(member.roles.cache.map(role => role.id)); - const current_skill_level = current_skill_role ? this.wheatley.get_skill_role_index(current_skill_role) : -1; - if ( - current_skill_level > skill_roles_order.indexOf("beginner") && - current_skill_level > last_known_skill_level - ) { - assert(current_skill_role); - M.log("Detected skill level increase for", member.user.tag); - await this.skill_role_log.send({ - embeds: [ - new Discord.EmbedBuilder() - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setColor(unwrap(await this.wheatley.guild.roles.fetch(current_skill_role)).color) - .setDescription( - roles_entry?.last_known_skill_role - ? `<@&${roles_entry.last_known_skill_role}> -> <@&${current_skill_role}>` - : `<@&${current_skill_role}>`, - ), - ], - }); - } - } - async check_members() { M.log("Starting role checks"); try { diff --git a/src/modules/tccpp/components/anti-screenshot.ts b/src/modules/tccpp/components/anti-screenshot.ts index 6c2762c6..37897d53 100644 --- a/src/modules/tccpp/components/anti-screenshot.ts +++ b/src/modules/tccpp/components/anti-screenshot.ts @@ -103,9 +103,8 @@ export default class AntiScreenshot extends BotComponent { async anti_screenshot(starter_message: Discord.Message, thread: Discord.ThreadChannel) { await delay(1000); assert(starter_message); - assert(starter_message.member); - // trust people with skill roles - if (this.wheatley.has_skill_roles_other_than_beginner(starter_message.member)) { + // trust established members + if (await this.wheatley.is_established_member(starter_message.author)) { return; } // check if it has images and no code diff --git a/src/modules/tccpp/components/establishment.ts b/src/modules/tccpp/components/establishment.ts new file mode 100644 index 00000000..3a2b75b2 --- /dev/null +++ b/src/modules/tccpp/components/establishment.ts @@ -0,0 +1,34 @@ +import * as Discord from "discord.js"; +import { unwrap } from "../../../utils/misc.js"; +import { M } from "../../../utils/debugging-and-logging.js"; +import { BotComponent } from "../../../bot-component.js"; +import RoleManager from "../../../components/role-manager.js"; +import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; +import SkillRoles from "./skill-roles.js"; + +export default class TheEstablishment extends BotComponent { + // private established_role!: Discord.Role; + + override async setup(commands: CommandSetBuilder) { + // const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; + // role_manager.register_role_check(this.check_established.bind(this)); + this.wheatley.is_established_member = this.is_established_member.bind(this); + } + + private async is_established_member( + options: Discord.GuildMember | Discord.User | Discord.UserResolvable | Discord.FetchMemberOptions, + ) { + const member = await this.wheatley.try_fetch_guild_member(options); + if (!member) { + return false; + } + return ( + SkillRoles.find_highest_skill_role_index(member.roles.cache) > 0 || + member.premiumSince != null || + member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) || + member.permissions.has(Discord.PermissionFlagsBits.ModerateMembers) + ); + } + + // private async check_established(member: Discord.GuildMember) {} +} diff --git a/src/modules/tccpp/components/formatting-error-detection.ts b/src/modules/tccpp/components/formatting-error-detection.ts index ceb7691f..77ab9db0 100644 --- a/src/modules/tccpp/components/formatting-error-detection.ts +++ b/src/modules/tccpp/components/formatting-error-detection.ts @@ -5,6 +5,7 @@ import { strict as assert } from "assert"; import { M } from "../../../utils/debugging-and-logging.js"; import { colors, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; +import SkillRoles from "./skill-roles.js"; import { build_description, parse_out } from "../../../utils/strings.js"; import Code from "../../../components/code.js"; import { SelfClearingMap, SelfClearingSet } from "../../../utils/containers.js"; @@ -121,10 +122,8 @@ export default class FormattingErrorDetection extends BotComponent { } has_likely_format_errors(message: Discord.Message) { - const has_skill_roles_other_than_beginner = message.member - ? this.wheatley.has_skill_roles_other_than_beginner(message.member) - : false; - if (has_skill_roles_other_than_beginner) { + // trust Proficient+ members + if (message.member && SkillRoles.find_highest_skill_role_index(message.member.roles.cache) >= 2) { return false; } return FormattingErrorDetection.has_likely_format_errors(message.content); diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 95642b3a..b8f629ed 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -7,6 +7,7 @@ import { HOUR } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { Wheatley } from "../../../wheatley.js"; import { unwrap } from "../../../utils/misc.js"; +import { skill_role_ids } from "./skill-roles.js"; const categories_map = { staff_logs: "1135927261472755712", @@ -138,10 +139,10 @@ export default class PermissionManager extends BotComponent { deny: [...acive_voice_permissions, SET_VOICE_STATUS_PERMISSION_BIT], }, [this.wheatley.roles.voice.id]: { allow: acive_voice_permissions }, - [this.wheatley.skill_roles.intermediate.id]: { allow: acive_voice_permissions }, - [this.wheatley.skill_roles.proficient.id]: { allow: acive_voice_permissions }, - [this.wheatley.skill_roles.advanced.id]: { allow: acive_voice_permissions }, - [this.wheatley.skill_roles.expert.id]: { allow: acive_voice_permissions }, + [skill_role_ids[1]]: { allow: acive_voice_permissions }, + [skill_role_ids[2]]: { allow: acive_voice_permissions }, + [skill_role_ids[3]]: { allow: acive_voice_permissions }, + [skill_role_ids[4]]: { allow: acive_voice_permissions }, [this.wheatley.roles.server_booster.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.no_voice.id]: no_interaction_at_all, [this.wheatley.roles.no_off_topic.id]: no_interaction_at_all, diff --git a/src/modules/tccpp/components/skill-role-suggestion.ts b/src/modules/tccpp/components/skill-role-suggestion.ts index 46e108cf..4089addb 100644 --- a/src/modules/tccpp/components/skill-role-suggestion.ts +++ b/src/modules/tccpp/components/skill-role-suggestion.ts @@ -9,7 +9,7 @@ import { M } from "../../../utils/debugging-and-logging.js"; import { colors, DAY, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; -import { skill_roles_order, Wheatley } from "../../../wheatley.js"; +import SkillRoles from "./skill-roles.js"; import { UserContextMenuInteractionBuilder, MessageContextMenuInteractionBuilder, @@ -19,8 +19,6 @@ import { build_description, capitalize } from "../../../utils/strings.js"; import { EarlyReplyMode, TextBasedCommandBuilder } from "../../../command-abstractions/text-based-command-builder.js"; import { TextBasedCommand } from "../../../command-abstractions/text-based-command.js"; -type interaction_context = { member: Discord.GuildMember; role?: string; context?: Discord.Message }; - type skill_level = "beginner" | "intermediate" | "proficient" | "advanced" | "expert"; type skill_suggestion_entry = { @@ -37,7 +35,13 @@ type skill_suggestion_thread_entry = { thread_closed: number | null; }; +const skill_levels: skill_level[] = ["beginner", "intermediate", "proficient", "advanced", "expert"]; + +type interaction_context = { member: Discord.GuildMember; level?: skill_level; context?: Discord.Message }; + export default class SkillRoleSuggestion extends BotComponent { + private skill_roles!: SkillRoles; + readonly target_map = new SelfClearingMap(15 * MINUTE); private database = this.wheatley.database.create_proxy<{ @@ -49,9 +53,11 @@ export default class SkillRoleSuggestion extends BotComponent { private suggestion_modal!: BotModal<[]>; override async setup(commands: CommandSetBuilder) { + this.skill_roles = unwrap(this.wheatley.components.get("SkillRoles")) as SkillRoles; this.skill_role_suggestions = await this.utilities.get_forum_channel( this.wheatley.channels.skill_role_suggestions, ); + commands.add( new UserContextMenuInteractionBuilder("Suggest Skill Role User").set_handler( this.skill_suggestion.bind(this), @@ -113,21 +119,13 @@ export default class SkillRoleSuggestion extends BotComponent { const context = interaction instanceof Discord.MessageContextMenuCommandInteraction ? interaction.targetMessage : undefined; this.target_map.set(interaction.user.id, { member, context }); - const target_skill_index = Math.max( - ...member.roles.cache - .filter(r => Object.values(this.wheatley.skill_roles).some(skill_role => r.id == skill_role.id)) - .map(role => this.wheatley.get_skill_role_index(role.id)), - ); - const suggestor_skill_index = Math.max( - ...suggester.roles.cache - .filter(r => Object.values(this.wheatley.skill_roles).some(skill_role => r.id == skill_role.id)) - .map(role => this.wheatley.get_skill_role_index(role.id)), - ); - const skill_roles_available = skill_roles_order.slice( + const target_skill_index = SkillRoles.find_highest_skill_role_index(member.roles.cache); + const suggestor_skill_index = SkillRoles.find_highest_skill_role_index(suggester.roles.cache); + const skill_levels_available = skill_levels.slice( target_skill_index + 1, // can't suggest anything <= the target's skill Math.max(suggestor_skill_index, 0) + 2, // can't suggest anything >= suggestor's skill + 1 ); - if (skill_roles_available.length == 0) { + if (skill_levels_available.length == 0) { await interaction.editReply({ content: `Unable to suggest skill roles for this user, they are either the max role or exceed yours`, }); @@ -137,9 +135,9 @@ export default class SkillRoleSuggestion extends BotComponent { components: [ new Discord.ActionRowBuilder().addComponents( new Discord.StringSelectMenuBuilder().setCustomId("skill-role-suggestion-picker").setOptions( - skill_roles_available.map(role => ({ - label: capitalize(role), - value: capitalize(role), + skill_levels_available.map(level => ({ + label: capitalize(level), + value: level, })), ), ), @@ -177,7 +175,7 @@ export default class SkillRoleSuggestion extends BotComponent { content: build_description( `<@${member.id}> ${member.displayName}`, `Suggested roles: ${sorted_filtered_counts - .map(([level, count]) => `${count}x<@&${this.wheatley.skill_roles[level].id}>`) + .map(([level, count]) => `${count}x<@&${this.skill_roles.roles[skill_levels.indexOf(level)].id}>`) .join(", ")}`, ), }; @@ -245,30 +243,31 @@ export default class SkillRoleSuggestion extends BotComponent { async handle_suggestion( member: Discord.GuildMember, suggester: Discord.GuildMember, - role: string, + level: skill_level, comments: string, context: Discord.Message | undefined, ) { + const role = this.skill_roles.roles[skill_levels.indexOf(level)]; const suggestion_time = Date.now(); const res = await this.database.skill_role_suggestions.insertOne({ user_id: member.user.id, suggested_by: suggester.user.id, time: suggestion_time, - level: role.toLowerCase() as skill_level, + level: level, }); assert(res.acknowledged); const thread = await this.update_or_make_thread(member, suggestion_time); await thread.send({ embeds: [ new Discord.EmbedBuilder() - .setColor(this.wheatley.skill_roles[role.toLowerCase() as skill_level].color) + .setColor(role.color) .setAuthor({ name: member.displayName, iconURL: member.avatarURL() ?? member.user.displayAvatarURL(), }) .setDescription( build_description( - `Skill role suggestion for <@${member.user.id}>: **${role}**`, + `Skill role suggestion for <@${member.user.id}>: **${role.name}**`, `Suggested by: <@${suggester.user.id}>`, `Context: ${context ? `[link](${context.url})` : "None (user context menu)"}`, comments.length > 0 ? `Comments: ${comments}` : null, @@ -283,7 +282,7 @@ export default class SkillRoleSuggestion extends BotComponent { } async launch_modal(interaction: Discord.StringSelectMenuInteraction) { - this.target_map.get(interaction.user.id)!.role = interaction.values[0]; + this.target_map.get(interaction.user.id)!.level = interaction.values[0] as skill_level; const modal = this.suggestion_modal.create_modal(); await interaction.showModal(modal); } @@ -297,10 +296,10 @@ export default class SkillRoleSuggestion extends BotComponent { interaction.member instanceof Discord.GuildMember ? interaction.member : await this.wheatley.guild.members.fetch(interaction.user.id); - const { member, role, context } = unwrap(this.target_map.get(interaction.user.id)); + const { member, level, context } = unwrap(this.target_map.get(interaction.user.id)); const comments = interaction.get_field_value("skill-role-suggestion-modal-comments"); // TODO: Why does role need to be unwrapped? - await this.handle_suggestion(member, suggester, unwrap(role), comments, context); + await this.handle_suggestion(member, suggester, unwrap(level), comments, context); await interaction.editReply({ content: "Thank you, your suggestion has been received", }); diff --git a/src/modules/tccpp/components/skill-roles.ts b/src/modules/tccpp/components/skill-roles.ts new file mode 100644 index 00000000..41802d7a --- /dev/null +++ b/src/modules/tccpp/components/skill-roles.ts @@ -0,0 +1,98 @@ +import * as Discord from "discord.js"; +import { strict as assert } from "assert"; +import { colors, HOUR, MINUTE } from "../../../common.js"; +import { unwrap } from "../../../utils/misc.js"; +import { M } from "../../../utils/debugging-and-logging.js"; +import { Wheatley } from "../../../wheatley.js"; +import { BotComponent } from "../../../bot-component.js"; +import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; +import RoleManager, { user_role_entry } from "../../../components/role-manager.js"; + +export const skill_role_ids = [ + "784733371275673600", // beginner + "331876085820030978", // intermediate + "849399021838925834", // proficient + "331719590990184450", // advanced + "331719591405551616", // expert +]; + +export default class SkillRoles extends BotComponent { + private skill_role_log!: Discord.TextChannel; + private skill_roles!: Discord.Role[]; + + get roles() { + return this.skill_roles; + } + + database = unwrap(this.wheatley.database).create_proxy<{ + user_roles: user_role_entry & { last_known_skill_role: string | null }; + }>(); + + override async setup(commands: CommandSetBuilder) { + this.skill_role_log = await this.utilities.get_channel(this.wheatley.channels.skill_role_log); + const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; + role_manager.register_role_check(this.check_skill_roles.bind(this)); + role_manager.register_role_update_listener(new Set(skill_role_ids), this.on_skill_role_change.bind(this)); + } + + override async on_ready(): Promise { + this.skill_roles = await Promise.all( + skill_role_ids.map(async (id, index) => { + const role = await this.wheatley.guild.roles.fetch(id); + assert(role !== null, `Skill role ${id} not found`); + return role; + }), + ); + } + + static find_highest_skill_role_index(roles: ReadonlySetLike) { + return skill_role_ids.findLastIndex(id => roles.has(id)); + } + + private async check_skill_roles(member: Discord.GuildMember) { + const skill_roles = skill_role_ids.filter(id => member.roles.cache.has(id)); + if (skill_roles.length > 1) { + M.log("removing redundant skill roles for", member.user.tag); + for (const id of skill_roles.slice(0, -1)) { + await member.roles.remove(id); + } + } + } + + private async on_skill_role_change(member: Discord.GuildMember) { + const roles_entry = await this.database.user_roles.findOne({ user_id: member.id }); + const last_known_skill_level = roles_entry?.last_known_skill_role + ? skill_role_ids.indexOf(roles_entry.last_known_skill_role) + : -1; + const current_skill_level = SkillRoles.find_highest_skill_role_index(member.roles.cache); + if (current_skill_level > last_known_skill_level) { + M.log("Detected skill level increase for", member.user.tag); + const current_skill_role = this.roles[current_skill_level]; + assert(current_skill_role); + if (current_skill_level > 0) { + // don't announce self-assigned roles + await this.skill_role_log.send({ + embeds: [ + new Discord.EmbedBuilder() + .setAuthor({ + name: member.displayName, + iconURL: member.displayAvatarURL(), + }) + .setColor(current_skill_role.color) + .setDescription( + roles_entry?.last_known_skill_role + ? `<@&${roles_entry.last_known_skill_role}> -> <@&${current_skill_role.id}>` + : `<@&${current_skill_role.id}>`, + ), + ], + }); + } + // only update db state once role has been announced + await this.database.user_roles.findOneAndUpdate( + { user_id: member.id }, + { $set: { last_known_skill_role: current_skill_role.id } }, + { upsert: true }, + ); + } + } +} diff --git a/src/wheatley.ts b/src/wheatley.ts index 34e3de13..5ec34112 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -5,7 +5,7 @@ import * as Discord from "discord.js"; import * as mongo from "mongodb"; import PromClient from "prom-client"; -import { colors, MINUTE } from "./common.js"; +import { colors, MINUTE, DAY } from "./common.js"; import { unwrap } from "./utils/misc.js"; import { to_string, is_string } from "./utils/strings.js"; import { globIterateSync } from "glob"; @@ -169,24 +169,6 @@ const roles_map = { voice: "1368073548983308328", }; -const skill_roles_map = { - beginner: "784733371275673600", - intermediate: "331876085820030978", - proficient: "849399021838925834", - advanced: "331719590990184450", - expert: "331719591405551616", -}; - -export const skill_roles_order = ["beginner", "intermediate", "proficient", "advanced", "expert"]; - -export const skill_roles_order_id = [ - "784733371275673600", - "331876085820030978", - "849399021838925834", - "331719590990184450", - "331719591405551616", -]; - type EventMap = { wheatley_ready: () => void; issue_moderation: (moderation: moderation_entry) => void; @@ -240,9 +222,6 @@ export class Wheatley { readonly roles: { [k in keyof typeof roles_map]: Discord.Role; } = {} as any; - readonly skill_roles: { - [k in keyof typeof skill_roles_map]: Discord.Role; - } = {} as any; message_counter = new PromClient.Counter({ name: "tccpp_message_count", @@ -414,17 +393,6 @@ export class Wheatley { M.log(`Fetched role ${k}`); }), ); - await Promise.all( - Object.entries(skill_roles_map).map(async ([k, id]) => { - const role = await wrap(() => this.guild.roles.fetch(id)); - if (this.freestanding && role === null) { - return; - } - assert(role !== null, `Role ${k} ${id} not found`); - this.skill_roles[k as keyof typeof skill_roles_map] = role; - M.log(`Fetched role ${k}`); - }), - ); } async add_component(component: { new (w: Wheatley): T; get is_freestanding(): boolean }) { @@ -609,16 +577,18 @@ export class Wheatley { return roots.length > 1 ? roots.slice(0, -1).join(", ") + `, or ${roots[roots.length - 1]}` : roots[0]; } - has_skill_roles_other_than_beginner(member: Discord.GuildMember) { - const non_beginner_skill_role_ids = Object.entries(this.skill_roles) - .filter(([name, _]) => name !== "beginner") - .map(([_, role]) => role.id); - return member.roles.cache.some(role => non_beginner_skill_role_ids.includes(role.id)); - } - - // higher is better - get_skill_role_index(role: Discord.Role | string) { - return skill_roles_order_id.indexOf(role instanceof Discord.Role ? role.id : role); + async is_established_member( + options: Discord.GuildMember | Discord.User | Discord.UserResolvable | Discord.FetchMemberOptions, + ) { + const member = await this.try_fetch_guild_member(options); + if (!member || (member.joinedAt && member.joinedAt.getDate() + 28 * DAY <= Date.now())) { + return false; + } + return ( + member.premiumSince != null || + member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) || + member.permissions.has(Discord.PermissionFlagsBits.ModerateMembers) + ); } async is_public_channel(channel: Discord.GuildTextBasedChannel | Discord.TextBasedChannel) { From b98f6a602c79334a2600224d96c55d7354351b7e Mon Sep 17 00:00:00 2001 From: Michael Kenzel Date: Sat, 11 Oct 2025 02:58:19 +0200 Subject: [PATCH 2/7] delete commented code --- src/modules/tccpp/components/establishment.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/modules/tccpp/components/establishment.ts b/src/modules/tccpp/components/establishment.ts index 3a2b75b2..5737e863 100644 --- a/src/modules/tccpp/components/establishment.ts +++ b/src/modules/tccpp/components/establishment.ts @@ -2,16 +2,11 @@ import * as Discord from "discord.js"; import { unwrap } from "../../../utils/misc.js"; import { M } from "../../../utils/debugging-and-logging.js"; import { BotComponent } from "../../../bot-component.js"; -import RoleManager from "../../../components/role-manager.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import SkillRoles from "./skill-roles.js"; export default class TheEstablishment extends BotComponent { - // private established_role!: Discord.Role; - override async setup(commands: CommandSetBuilder) { - // const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; - // role_manager.register_role_check(this.check_established.bind(this)); this.wheatley.is_established_member = this.is_established_member.bind(this); } @@ -29,6 +24,4 @@ export default class TheEstablishment extends BotComponent { member.permissions.has(Discord.PermissionFlagsBits.ModerateMembers) ); } - - // private async check_established(member: Discord.GuildMember) {} } From b92ea2365054c23be6e109107aa1a5eaad61852c Mon Sep 17 00:00:00 2001 From: Michael Kenzel Date: Sat, 11 Oct 2025 02:59:37 +0200 Subject: [PATCH 3/7] move skill_level and skill_levels --- src/modules/tccpp/components/skill-role-suggestion.ts | 6 +----- src/modules/tccpp/components/skill-roles.ts | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/modules/tccpp/components/skill-role-suggestion.ts b/src/modules/tccpp/components/skill-role-suggestion.ts index 4089addb..e8bfec3c 100644 --- a/src/modules/tccpp/components/skill-role-suggestion.ts +++ b/src/modules/tccpp/components/skill-role-suggestion.ts @@ -9,7 +9,7 @@ import { M } from "../../../utils/debugging-and-logging.js"; import { colors, DAY, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; -import SkillRoles from "./skill-roles.js"; +import SkillRoles, { skill_level, skill_levels } from "./skill-roles.js"; import { UserContextMenuInteractionBuilder, MessageContextMenuInteractionBuilder, @@ -19,8 +19,6 @@ import { build_description, capitalize } from "../../../utils/strings.js"; import { EarlyReplyMode, TextBasedCommandBuilder } from "../../../command-abstractions/text-based-command-builder.js"; import { TextBasedCommand } from "../../../command-abstractions/text-based-command.js"; -type skill_level = "beginner" | "intermediate" | "proficient" | "advanced" | "expert"; - type skill_suggestion_entry = { user_id: string; suggested_by: string; @@ -35,8 +33,6 @@ type skill_suggestion_thread_entry = { thread_closed: number | null; }; -const skill_levels: skill_level[] = ["beginner", "intermediate", "proficient", "advanced", "expert"]; - type interaction_context = { member: Discord.GuildMember; level?: skill_level; context?: Discord.Message }; export default class SkillRoleSuggestion extends BotComponent { diff --git a/src/modules/tccpp/components/skill-roles.ts b/src/modules/tccpp/components/skill-roles.ts index 41802d7a..7f6f2638 100644 --- a/src/modules/tccpp/components/skill-roles.ts +++ b/src/modules/tccpp/components/skill-roles.ts @@ -8,6 +8,9 @@ import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import RoleManager, { user_role_entry } from "../../../components/role-manager.js"; +export type skill_level = "beginner" | "intermediate" | "proficient" | "advanced" | "expert"; +export const skill_levels: skill_level[] = ["beginner", "intermediate", "proficient", "advanced", "expert"]; + export const skill_role_ids = [ "784733371275673600", // beginner "331876085820030978", // intermediate From fae936b4bbe026738ba2b34564a2064b17409bdd Mon Sep 17 00:00:00 2001 From: Michael Kenzel Date: Sun, 12 Oct 2025 02:55:10 +0200 Subject: [PATCH 4/7] introduce SkillLevel enum to replace array mess --- src/modules/tccpp/components/establishment.ts | 7 ++- .../components/formatting-error-detection.ts | 12 +++- .../tccpp/components/permissions-manager.ts | 17 ++++-- .../tccpp/components/skill-role-suggestion.ts | 24 ++++---- src/modules/tccpp/components/skill-roles.ts | 60 ++++++++++--------- 5 files changed, 71 insertions(+), 49 deletions(-) diff --git a/src/modules/tccpp/components/establishment.ts b/src/modules/tccpp/components/establishment.ts index 5737e863..7735b912 100644 --- a/src/modules/tccpp/components/establishment.ts +++ b/src/modules/tccpp/components/establishment.ts @@ -3,10 +3,13 @@ import { unwrap } from "../../../utils/misc.js"; import { M } from "../../../utils/debugging-and-logging.js"; import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; -import SkillRoles from "./skill-roles.js"; +import SkillRoles, { SkillLevel } from "./skill-roles.js"; export default class TheEstablishment extends BotComponent { + private skill_roles!: SkillRoles; + override async setup(commands: CommandSetBuilder) { + this.skill_roles = unwrap(this.wheatley.components.get("SkillRoles")) as SkillRoles; this.wheatley.is_established_member = this.is_established_member.bind(this); } @@ -18,7 +21,7 @@ export default class TheEstablishment extends BotComponent { return false; } return ( - SkillRoles.find_highest_skill_role_index(member.roles.cache) > 0 || + this.skill_roles.find_highest_skill_level(member) > SkillLevel.Beginner || member.premiumSince != null || member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) || member.permissions.has(Discord.PermissionFlagsBits.ModerateMembers) diff --git a/src/modules/tccpp/components/formatting-error-detection.ts b/src/modules/tccpp/components/formatting-error-detection.ts index 77ab9db0..66d2453f 100644 --- a/src/modules/tccpp/components/formatting-error-detection.ts +++ b/src/modules/tccpp/components/formatting-error-detection.ts @@ -5,11 +5,13 @@ import { strict as assert } from "assert"; import { M } from "../../../utils/debugging-and-logging.js"; import { colors, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; -import SkillRoles from "./skill-roles.js"; +import SkillRoles, { SkillLevel } from "./skill-roles.js"; import { build_description, parse_out } from "../../../utils/strings.js"; import Code from "../../../components/code.js"; import { SelfClearingMap, SelfClearingSet } from "../../../utils/containers.js"; import * as dismark from "dismark"; +import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; +import { unwrap } from "../../../utils/misc.js"; const FAILED_CODE_BLOCK_RE = /^(?:"""?|'''?)(.+?)(?:"""?|'''?|$)/s; @@ -39,6 +41,8 @@ class FailedCodeBlockRule extends dismark.Rule { } export default class FormattingErrorDetection extends BotComponent { + private skill_roles!: SkillRoles; + messaged = new SelfClearingSet(10 * MINUTE); // trigger message -> reply replies = new SelfClearingMap(10 * MINUTE); @@ -121,9 +125,13 @@ export default class FormattingErrorDetection extends BotComponent { return (has_likely_format_mistakes as boolean) && !(has_properly_formatted_code_blocks as boolean); } + override async setup(commands: CommandSetBuilder) { + this.skill_roles = unwrap(this.wheatley.components.get("SkillRoles")) as SkillRoles; + } + has_likely_format_errors(message: Discord.Message) { // trust Proficient+ members - if (message.member && SkillRoles.find_highest_skill_role_index(message.member.roles.cache) >= 2) { + if (message.member && this.skill_roles.find_highest_skill_level(message.member) >= SkillLevel.Proficient) { return false; } return FormattingErrorDetection.has_likely_format_errors(message.content); diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index b8f629ed..a44211d0 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -5,9 +5,10 @@ import { strict as assert } from "assert"; import { M } from "../../../utils/debugging-and-logging.js"; import { HOUR } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; +import SkillRoles from "./skill-roles.js"; import { Wheatley } from "../../../wheatley.js"; import { unwrap } from "../../../utils/misc.js"; -import { skill_role_ids } from "./skill-roles.js"; +import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; const categories_map = { staff_logs: "1135927261472755712", @@ -39,9 +40,15 @@ type permission_overwrites = Record; const SET_VOICE_STATUS_PERMISSION_BIT = 1n << 48n; // TODO: Replace once discord.js supports this in PermissionsBitField export default class PermissionManager extends BotComponent { + private skill_roles!: SkillRoles; + category_permissions: Record = {}; channel_overrides: Record = {}; + override async setup(commands: CommandSetBuilder) { + this.skill_roles = unwrap(this.wheatley.components.get("SkillRoles")) as SkillRoles; + } + setup_permissions_map() { // permission sets const write_permissions = [ @@ -139,10 +146,10 @@ export default class PermissionManager extends BotComponent { deny: [...acive_voice_permissions, SET_VOICE_STATUS_PERMISSION_BIT], }, [this.wheatley.roles.voice.id]: { allow: acive_voice_permissions }, - [skill_role_ids[1]]: { allow: acive_voice_permissions }, - [skill_role_ids[2]]: { allow: acive_voice_permissions }, - [skill_role_ids[3]]: { allow: acive_voice_permissions }, - [skill_role_ids[4]]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.Intermediate.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.Proficient.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.Advanced.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.Expert.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.server_booster.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.no_voice.id]: no_interaction_at_all, [this.wheatley.roles.no_off_topic.id]: no_interaction_at_all, diff --git a/src/modules/tccpp/components/skill-role-suggestion.ts b/src/modules/tccpp/components/skill-role-suggestion.ts index e8bfec3c..c8334d45 100644 --- a/src/modules/tccpp/components/skill-role-suggestion.ts +++ b/src/modules/tccpp/components/skill-role-suggestion.ts @@ -9,7 +9,7 @@ import { M } from "../../../utils/debugging-and-logging.js"; import { colors, DAY, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; -import SkillRoles, { skill_level, skill_levels } from "./skill-roles.js"; +import SkillRoles, { SkillLevel, skill_level } from "./skill-roles.js"; import { UserContextMenuInteractionBuilder, MessageContextMenuInteractionBuilder, @@ -115,9 +115,9 @@ export default class SkillRoleSuggestion extends BotComponent { const context = interaction instanceof Discord.MessageContextMenuCommandInteraction ? interaction.targetMessage : undefined; this.target_map.set(interaction.user.id, { member, context }); - const target_skill_index = SkillRoles.find_highest_skill_role_index(member.roles.cache); - const suggestor_skill_index = SkillRoles.find_highest_skill_role_index(suggester.roles.cache); - const skill_levels_available = skill_levels.slice( + const target_skill_index = this.skill_roles.find_highest_skill_level(member); + const suggestor_skill_index = this.skill_roles.find_highest_skill_level(suggester); + const skill_levels_available = Object.keys(SkillLevel).slice( target_skill_index + 1, // can't suggest anything <= the target's skill Math.max(suggestor_skill_index, 0) + 2, // can't suggest anything >= suggestor's skill + 1 ); @@ -150,17 +150,17 @@ export default class SkillRoleSuggestion extends BotComponent { .find({ user_id: member.id, time: { $gte: thread_start_time } }) .toArray(); const counts: Record = { - beginner: 0, - intermediate: 0, - proficient: 0, - advanced: 0, - expert: 0, + Beginner: 0, + Intermediate: 0, + Proficient: 0, + Advanced: 0, + Expert: 0, }; for (const suggestion of suggestions) { assert(suggestion.level in counts); counts[suggestion.level]++; } - const sorted_filtered_counts = (Object.entries(counts) as [skill_level, number][]) + const sorted_filtered_counts = Object.entries(counts) .toSorted((a, b) => descending(a, b, v => v[1])) .filter(v => v[1] !== 0); const suggested_levels = sorted_filtered_counts.map(([k, _]) => k); @@ -171,7 +171,7 @@ export default class SkillRoleSuggestion extends BotComponent { content: build_description( `<@${member.id}> ${member.displayName}`, `Suggested roles: ${sorted_filtered_counts - .map(([level, count]) => `${count}x<@&${this.skill_roles.roles[skill_levels.indexOf(level)].id}>`) + .map(([level, count]) => `${count}x<@&${this.skill_roles.roles[level as skill_level].id}>`) .join(", ")}`, ), }; @@ -243,7 +243,7 @@ export default class SkillRoleSuggestion extends BotComponent { comments: string, context: Discord.Message | undefined, ) { - const role = this.skill_roles.roles[skill_levels.indexOf(level)]; + const role = this.skill_roles.roles[level]; const suggestion_time = Date.now(); const res = await this.database.skill_role_suggestions.insertOne({ user_id: member.user.id, diff --git a/src/modules/tccpp/components/skill-roles.ts b/src/modules/tccpp/components/skill-roles.ts index 7f6f2638..dd322e12 100644 --- a/src/modules/tccpp/components/skill-roles.ts +++ b/src/modules/tccpp/components/skill-roles.ts @@ -8,24 +8,22 @@ import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import RoleManager, { user_role_entry } from "../../../components/role-manager.js"; -export type skill_level = "beginner" | "intermediate" | "proficient" | "advanced" | "expert"; -export const skill_levels: skill_level[] = ["beginner", "intermediate", "proficient", "advanced", "expert"]; - -export const skill_role_ids = [ - "784733371275673600", // beginner - "331876085820030978", // intermediate - "849399021838925834", // proficient - "331719590990184450", // advanced - "331719591405551616", // expert -]; +export enum SkillLevel { + Beginner, + Intermediate, + Proficient, + Advanced, + Expert, +} +export type skill_level = keyof typeof SkillLevel; export default class SkillRoles extends BotComponent { private skill_role_log!: Discord.TextChannel; private skill_roles!: Discord.Role[]; - get roles() { - return this.skill_roles; - } + public readonly roles: { + [k in skill_level]: Discord.Role; + } = {} as any; database = unwrap(this.wheatley.database).create_proxy<{ user_roles: user_role_entry & { last_known_skill_role: string | null }; @@ -33,27 +31,33 @@ export default class SkillRoles extends BotComponent { override async setup(commands: CommandSetBuilder) { this.skill_role_log = await this.utilities.get_channel(this.wheatley.channels.skill_role_log); - const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; - role_manager.register_role_check(this.check_skill_roles.bind(this)); - role_manager.register_role_update_listener(new Set(skill_role_ids), this.on_skill_role_change.bind(this)); } override async on_ready(): Promise { - this.skill_roles = await Promise.all( - skill_role_ids.map(async (id, index) => { - const role = await this.wheatley.guild.roles.fetch(id); - assert(role !== null, `Skill role ${id} not found`); - return role; - }), + for (const name in SkillLevel) { + const role = this.wheatley.get_role_by_name(name); + this.skill_roles.push(role); + this.roles[name as skill_level] = role; + } + const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; + role_manager.register_role_check(this.check_skill_roles.bind(this)); + role_manager.register_role_update_listener( + new Set(this.skill_roles.map(r => r.id)), + this.on_skill_role_change.bind(this), ); } - static find_highest_skill_role_index(roles: ReadonlySetLike) { - return skill_role_ids.findLastIndex(id => roles.has(id)); + find_highest_skill_level(member: Discord.GuildMember): number; + find_highest_skill_level(roles: ReadonlySetLike): number; + find_highest_skill_level(options: ReadonlySetLike | Discord.GuildMember) { + if (options instanceof Discord.GuildMember) { + return this.find_highest_skill_level(options.roles.cache); + } + return this.skill_roles.findLastIndex(r => options.has(r.id)); } private async check_skill_roles(member: Discord.GuildMember) { - const skill_roles = skill_role_ids.filter(id => member.roles.cache.has(id)); + const skill_roles = this.skill_roles.filter(r => member.roles.cache.has(r.id)); if (skill_roles.length > 1) { M.log("removing redundant skill roles for", member.user.tag); for (const id of skill_roles.slice(0, -1)) { @@ -65,12 +69,12 @@ export default class SkillRoles extends BotComponent { private async on_skill_role_change(member: Discord.GuildMember) { const roles_entry = await this.database.user_roles.findOne({ user_id: member.id }); const last_known_skill_level = roles_entry?.last_known_skill_role - ? skill_role_ids.indexOf(roles_entry.last_known_skill_role) + ? this.skill_roles.findIndex(r => r.id == roles_entry.last_known_skill_role) : -1; - const current_skill_level = SkillRoles.find_highest_skill_role_index(member.roles.cache); + const current_skill_level = this.find_highest_skill_level(member); if (current_skill_level > last_known_skill_level) { M.log("Detected skill level increase for", member.user.tag); - const current_skill_role = this.roles[current_skill_level]; + const current_skill_role = this.skill_roles[current_skill_level]; assert(current_skill_role); if (current_skill_level > 0) { // don't announce self-assigned roles From 6c6b66d99577fbac2662cb7bb7c72b2a1843768a Mon Sep 17 00:00:00 2001 From: Michael Kenzel Date: Tue, 14 Oct 2025 02:58:47 +0200 Subject: [PATCH 5/7] switch from enum to const object --- src/modules/tccpp/components/skill-roles.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/modules/tccpp/components/skill-roles.ts b/src/modules/tccpp/components/skill-roles.ts index dd322e12..f1915ca9 100644 --- a/src/modules/tccpp/components/skill-roles.ts +++ b/src/modules/tccpp/components/skill-roles.ts @@ -8,18 +8,19 @@ import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import RoleManager, { user_role_entry } from "../../../components/role-manager.js"; -export enum SkillLevel { - Beginner, - Intermediate, - Proficient, - Advanced, - Expert, -} +// eslint-disable-next-line @typescript-eslint/naming-convention +export const SkillLevel = { + Beginner: 0, + Intermediate: 1, + Proficient: 2, + Advanced: 3, + Expert: 4, +} as const; export type skill_level = keyof typeof SkillLevel; export default class SkillRoles extends BotComponent { private skill_role_log!: Discord.TextChannel; - private skill_roles!: Discord.Role[]; + private skill_roles: Discord.Role[] = []; public readonly roles: { [k in skill_level]: Discord.Role; @@ -33,7 +34,7 @@ export default class SkillRoles extends BotComponent { this.skill_role_log = await this.utilities.get_channel(this.wheatley.channels.skill_role_log); } - override async on_ready(): Promise { + override async on_ready() { for (const name in SkillLevel) { const role = this.wheatley.get_role_by_name(name); this.skill_roles.push(role); From 846f3c498f4203402e635dc613ad8590a3502330 Mon Sep 17 00:00:00 2001 From: Jeremy Rifkin <51220084+jeremy-rifkin@users.noreply.github.com> Date: Thu, 6 Nov 2025 22:25:51 -0600 Subject: [PATCH 6/7] Add some infrastructure for formerly banned user actions --- src/components/moderation/modlogs.ts | 6 +++- .../notify-about-formerly-banned-users.ts | 29 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/moderation/modlogs.ts b/src/components/moderation/modlogs.ts index c77a572a..d9c7589b 100644 --- a/src/components/moderation/modlogs.ts +++ b/src/components/moderation/modlogs.ts @@ -264,7 +264,7 @@ export default class Modlogs extends BotComponent { } async case_info(command: TextBasedCommand, case_number: number) { - const moderation = await this.database.moderations.findOne({ case_number }); + const moderation = await this.get_case(case_number); if (moderation) { await command.reply({ embeds: [ @@ -280,6 +280,10 @@ export default class Modlogs extends BotComponent { } } + async get_case(case_number: number) { + return await this.database.moderations.findOne({ case_number }); + } + // TODO: Code duplication async reply_with_error(command: TextBasedCommand, message: string) { await command.replyOrFollowUp({ diff --git a/src/components/notify-about-formerly-banned-users.ts b/src/components/notify-about-formerly-banned-users.ts index f1b75479..aae77757 100644 --- a/src/components/notify-about-formerly-banned-users.ts +++ b/src/components/notify-about-formerly-banned-users.ts @@ -9,6 +9,14 @@ import { moderation_entry } from "./moderation/schemata.js"; import { unwrap } from "../utils/misc.js"; import { CommandSetBuilder } from "../command-abstractions/command-set-builder.js"; import LinkedAccounts from "./linked-accounts.js"; +import { BotButton } from "../command-abstractions/button.js"; + +export type notify_plugin = { + maybe_create_button: ( + member: Discord.GuildMember, + most_recent: moderation_entry, + ) => Discord.ButtonBuilder | undefined; +}; export default class NotifyAboutFormerlyBannedUsers extends BotComponent { private staff_action_log!: Discord.TextChannel; @@ -16,27 +24,29 @@ export default class NotifyAboutFormerlyBannedUsers extends BotComponent { moderations: moderation_entry; }>(); private linked_accounts!: LinkedAccounts; + private plugins: notify_plugin[] = []; override async setup(commands: CommandSetBuilder) { this.staff_action_log = await this.utilities.get_channel(this.wheatley.channels.staff_action_log); this.linked_accounts = unwrap(this.wheatley.components.get("LinkedAccounts")) as LinkedAccounts; } + register_plugin(plugin: notify_plugin) { + this.plugins.push(plugin); + } + async alert(member: Discord.GuildMember, most_recent: moderation_entry, linked_accounts: Set) { const action = most_recent.type == "kick" ? "kicked" : "banned"; - const description_parts = [ `User <@${member.user.id}> was previously ${action} on ${discord_timestamp(most_recent.issued_at)}`, most_recent.reason ? `Reason: ${most_recent.reason}` : null, ]; - if (linked_accounts.size > 0) { const account_mentions = Array.from(linked_accounts) .map(id => `<@${id}>`) .join(", "); description_parts.push(`⚠️ User has ${linked_accounts.size} linked accounts: ${account_mentions}`); } - const embed = new Discord.EmbedBuilder() .setColor(colors.alert_color) .setAuthor({ @@ -48,7 +58,18 @@ export default class NotifyAboutFormerlyBannedUsers extends BotComponent { text: `ID: ${member.id}`, }) .setTimestamp(); - await this.staff_action_log.send({ embeds: [embed] }); + const components = (() => { + const buttons = this.plugins + .map(plugin => plugin.maybe_create_button(member, most_recent)) + .filter(x => x !== undefined); + return buttons.length > 0 + ? [new Discord.ActionRowBuilder().addComponents(...buttons)] + : undefined; + })(); + await this.staff_action_log.send({ + embeds: [embed], + components, + }); } async find_most_recent_kick_or_ban(user_ids: string[]) { From 7e0d5aa73696f771db44c609df741b987f2c3847 Mon Sep 17 00:00:00 2001 From: Jeremy Rifkin <51220084+jeremy-rifkin@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:56:05 -0600 Subject: [PATCH 7/7] Use lowercase names for skill levels, setup a migration to a new independent skill role collection, and a couple small cleanups --- src/modules/tccpp/components/establishment.ts | 2 +- .../components/formatting-error-detection.ts | 2 +- .../tccpp/components/permissions-manager.ts | 8 +- .../tccpp/components/skill-role-suggestion.ts | 10 +-- src/modules/tccpp/components/skill-roles.ts | 90 ++++++++++++------- 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/src/modules/tccpp/components/establishment.ts b/src/modules/tccpp/components/establishment.ts index 7735b912..01a3f41f 100644 --- a/src/modules/tccpp/components/establishment.ts +++ b/src/modules/tccpp/components/establishment.ts @@ -21,7 +21,7 @@ export default class TheEstablishment extends BotComponent { return false; } return ( - this.skill_roles.find_highest_skill_level(member) > SkillLevel.Beginner || + this.skill_roles.find_highest_skill_level(member) > SkillLevel.beginner || member.premiumSince != null || member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) || member.permissions.has(Discord.PermissionFlagsBits.ModerateMembers) diff --git a/src/modules/tccpp/components/formatting-error-detection.ts b/src/modules/tccpp/components/formatting-error-detection.ts index 1bfc725c..8ce20364 100644 --- a/src/modules/tccpp/components/formatting-error-detection.ts +++ b/src/modules/tccpp/components/formatting-error-detection.ts @@ -154,7 +154,7 @@ export default class FormattingErrorDetection extends BotComponent { has_likely_format_errors(message: Discord.Message) { // trust Proficient+ members - if (message.member && this.skill_roles.find_highest_skill_level(message.member) >= SkillLevel.Proficient) { + if (message.member && this.skill_roles.find_highest_skill_level(message.member) >= SkillLevel.proficient) { return false; } return FormattingErrorDetection.has_likely_format_errors(message.content); diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 0999e636..a44700d2 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -148,10 +148,10 @@ export default class PermissionManager extends BotComponent { }, [this.wheatley.roles.official_bot.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.voice.id]: { allow: acive_voice_permissions }, - [this.skill_roles.roles.Intermediate.id]: { allow: acive_voice_permissions }, - [this.skill_roles.roles.Proficient.id]: { allow: acive_voice_permissions }, - [this.skill_roles.roles.Advanced.id]: { allow: acive_voice_permissions }, - [this.skill_roles.roles.Expert.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.intermediate.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.proficient.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.advanced.id]: { allow: acive_voice_permissions }, + [this.skill_roles.roles.expert.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.server_booster.id]: { allow: acive_voice_permissions }, [this.wheatley.roles.no_voice.id]: no_interaction_at_all, [this.wheatley.roles.no_off_topic.id]: no_interaction_at_all, diff --git a/src/modules/tccpp/components/skill-role-suggestion.ts b/src/modules/tccpp/components/skill-role-suggestion.ts index c8334d45..1ab109a2 100644 --- a/src/modules/tccpp/components/skill-role-suggestion.ts +++ b/src/modules/tccpp/components/skill-role-suggestion.ts @@ -150,11 +150,11 @@ export default class SkillRoleSuggestion extends BotComponent { .find({ user_id: member.id, time: { $gte: thread_start_time } }) .toArray(); const counts: Record = { - Beginner: 0, - Intermediate: 0, - Proficient: 0, - Advanced: 0, - Expert: 0, + beginner: 0, + intermediate: 0, + proficient: 0, + advanced: 0, + expert: 0, }; for (const suggestion of suggestions) { assert(suggestion.level in counts); diff --git a/src/modules/tccpp/components/skill-roles.ts b/src/modules/tccpp/components/skill-roles.ts index f1915ca9..6608a83e 100644 --- a/src/modules/tccpp/components/skill-roles.ts +++ b/src/modules/tccpp/components/skill-roles.ts @@ -7,27 +7,29 @@ import { Wheatley } from "../../../wheatley.js"; import { BotComponent } from "../../../bot-component.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import RoleManager, { user_role_entry } from "../../../components/role-manager.js"; +import { capitalize } from "../../../utils/strings.js"; // eslint-disable-next-line @typescript-eslint/naming-convention export const SkillLevel = { - Beginner: 0, - Intermediate: 1, - Proficient: 2, - Advanced: 3, - Expert: 4, + beginner: 0, + intermediate: 1, + proficient: 2, + advanced: 3, + expert: 4, } as const; export type skill_level = keyof typeof SkillLevel; +type skill_role_entry = { user_id: string; last_known_skill_role: string | null }; + export default class SkillRoles extends BotComponent { private skill_role_log!: Discord.TextChannel; - private skill_roles: Discord.Role[] = []; - public readonly roles: { - [k in skill_level]: Discord.Role; - } = {} as any; + private skill_roles: Discord.Role[] = []; + public readonly roles = {} as Record; database = unwrap(this.wheatley.database).create_proxy<{ - user_roles: user_role_entry & { last_known_skill_role: string | null }; + user_roles: user_role_entry; + skill_roles: skill_role_entry; }>(); override async setup(commands: CommandSetBuilder) { @@ -36,10 +38,11 @@ export default class SkillRoles extends BotComponent { override async on_ready() { for (const name in SkillLevel) { - const role = this.wheatley.get_role_by_name(name); + const role = this.wheatley.get_role_by_name(capitalize(name)); this.skill_roles.push(role); this.roles[name as skill_level] = role; } + await this.maybe_migrate_user_role_entries(); const role_manager = unwrap(this.wheatley.components.get("RoleManager")) as RoleManager; role_manager.register_role_check(this.check_skill_roles.bind(this)); role_manager.register_role_update_listener( @@ -48,6 +51,32 @@ export default class SkillRoles extends BotComponent { ); } + async maybe_migrate_user_role_entries() { + const entries_to_migrate = await this.database.user_roles + .find({ last_known_skill_role: { $exists: true } }) + .toArray(); + if (entries_to_migrate.length === 0) { + return; + } + for (const entry of entries_to_migrate) { + await this.database.skill_roles.updateOne( + { user_id: entry.user_id }, + { + $set: { + user_id: entry.user_id, + last_known_skill_role: (entry as any).last_known_skill_role, + }, + }, + { upsert: true }, + ); + await this.database.user_roles.updateOne( + { user_id: entry.user_id }, + { $unset: { last_known_skill_role: "" } }, + ); + } + M.log(`Successfully migrated ${entries_to_migrate.length} skill role entries`); + } + find_highest_skill_level(member: Discord.GuildMember): number; find_highest_skill_level(roles: ReadonlySetLike): number; find_highest_skill_level(options: ReadonlySetLike | Discord.GuildMember) { @@ -68,35 +97,32 @@ export default class SkillRoles extends BotComponent { } private async on_skill_role_change(member: Discord.GuildMember) { - const roles_entry = await this.database.user_roles.findOne({ user_id: member.id }); + const roles_entry = await this.database.skill_roles.findOne({ user_id: member.id }); const last_known_skill_level = roles_entry?.last_known_skill_role ? this.skill_roles.findIndex(r => r.id == roles_entry.last_known_skill_role) : -1; const current_skill_level = this.find_highest_skill_level(member); - if (current_skill_level > last_known_skill_level) { + if (current_skill_level > SkillLevel.beginner && current_skill_level > last_known_skill_level) { M.log("Detected skill level increase for", member.user.tag); const current_skill_role = this.skill_roles[current_skill_level]; assert(current_skill_role); - if (current_skill_level > 0) { - // don't announce self-assigned roles - await this.skill_role_log.send({ - embeds: [ - new Discord.EmbedBuilder() - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setColor(current_skill_role.color) - .setDescription( - roles_entry?.last_known_skill_role - ? `<@&${roles_entry.last_known_skill_role}> -> <@&${current_skill_role.id}>` - : `<@&${current_skill_role.id}>`, - ), - ], - }); - } + await this.skill_role_log.send({ + embeds: [ + new Discord.EmbedBuilder() + .setAuthor({ + name: member.displayName, + iconURL: member.displayAvatarURL(), + }) + .setColor(current_skill_role.color) + .setDescription( + roles_entry?.last_known_skill_role + ? `<@&${roles_entry.last_known_skill_role}> -> <@&${current_skill_role.id}>` + : `<@&${current_skill_role.id}>`, + ), + ], + }); // only update db state once role has been announced - await this.database.user_roles.findOneAndUpdate( + await this.database.skill_roles.findOneAndUpdate( { user_id: member.id }, { $set: { last_known_skill_role: current_skill_role.id } }, { upsert: true },