From 8a43d122ffaa0c653af7f5c6a223c707e0fbab0c Mon Sep 17 00:00:00 2001 From: ianpike Date: Sun, 4 Jan 2026 11:29:39 -0500 Subject: [PATCH 1/4] Add in auto message for first-ever voice access --- .../wheatley/components/moderation/voice.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/modules/wheatley/components/moderation/voice.ts b/src/modules/wheatley/components/moderation/voice.ts index ed0c1c9f..2ef1fb6d 100644 --- a/src/modules/wheatley/components/moderation/voice.ts +++ b/src/modules/wheatley/components/moderation/voice.ts @@ -15,15 +15,28 @@ import { SelfClearingSet } from "../../../../utils/containers.js"; import { build_description } from "../../../../utils/strings.js"; import { unwrap } from "../../../../utils/misc.js"; +type voice_first_join_notice_entry = { + guild: string; + user: string; + first_seen_at: Date; + first_channel: string; +}; + export default class VoiceModeration extends BotComponent { private recently_in_voice = new SelfClearingSet(5 * MINUTE); private staff_action_log!: Discord.TextChannel; private voice_hotline!: Discord.TextChannel; + private database = this.wheatley.database.create_proxy<{ + voice_first_join_notice: voice_first_join_notice_entry; + }>(); + override async setup(commands: CommandSetBuilder) { this.staff_action_log = await this.utilities.get_channel(this.wheatley.channels.staff_action_log); this.voice_hotline = await this.utilities.get_channel(this.wheatley.channels.voice_hotline); + await this.database.voice_first_join_notice.createIndex({ guild: 1, user: 1 }, { unique: true }); + commands.add( new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral) .set_category("Misc") @@ -251,9 +264,51 @@ export default class VoiceModeration extends BotComponent { } override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { + // Track "recently in voice" for quarantine purposes if (!new_state.channel && new_state.member) { this.recently_in_voice.insert(new_state.member.id); } + + // First-ever voice join notice for users without permanent voice access + if ( + old_state.channelId == null && + new_state.channelId != null && + new_state.guild.id === this.wheatley.guild.id && + new_state.member != null && + !new_state.member.user.bot && + new_state.channelId !== this.wheatley.guild.afkChannelId + ) { + const member = new_state.member; + const res = await this.database.voice_first_join_notice.updateOne( + { guild: new_state.guild.id, user: member.id }, + { + $setOnInsert: { + guild: new_state.guild.id, + user: member.id, + first_seen_at: new Date(), + first_channel: new_state.channelId, + }, + }, + { upsert: true }, + ); + + if ( + res.upsertedCount > 0 && + !member.roles.cache.has(this.wheatley.roles.voice.id) && + !member.roles.cache.has(this.wheatley.roles.no_voice.id) && + new_state.channel?.isVoiceBased() + ) { + await new_state.channel.send({ + content: + `<@${member.id}> ` + + "new users are suppressed by default to protect our voice channels. " + + "You will be able to speak when joining a channel with a voice moderator present. " + + "Stick around and you will eventually be granted permanent voice access. " + + "Please do not ping voice moderators to be unsupressed.", + allowedMentions: { users: [member.id] }, + }); + } + } } override async on_audit_log_entry_create(entry: Discord.GuildAuditLogsEntry): Promise { From 80cc0d45e0659f24b37915a3faffe44bfb351d89 Mon Sep 17 00:00:00 2001 From: ianpike Date: Sun, 4 Jan 2026 11:38:53 -0500 Subject: [PATCH 2/4] Also check for booster status or skill role above beginner --- .../wheatley/components/moderation/voice.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/modules/wheatley/components/moderation/voice.ts b/src/modules/wheatley/components/moderation/voice.ts index 2ef1fb6d..3ca3cbb5 100644 --- a/src/modules/wheatley/components/moderation/voice.ts +++ b/src/modules/wheatley/components/moderation/voice.ts @@ -14,6 +14,7 @@ import { TextBasedCommand } from "../../../../command-abstractions/text-based-co import { SelfClearingSet } from "../../../../utils/containers.js"; import { build_description } from "../../../../utils/strings.js"; import { unwrap } from "../../../../utils/misc.js"; +import SkillRoles, { SkillLevel } from "../../../tccpp/components/skill-roles.js"; type voice_first_join_notice_entry = { guild: string; @@ -117,6 +118,17 @@ export default class VoiceModeration extends BotComponent { ); } + private has_skill_role_above_beginner(member: Discord.GuildMember) { + const skill_roles_component = this.wheatley.components.get("SkillRoles"); + if (skill_roles_component && skill_roles_component instanceof SkillRoles) { + return skill_roles_component.find_highest_skill_level(member) > SkillLevel.beginner; + } + + // If the SkillRoles component isn't loaded, check by role name. + const higher_skill_role_names = new Set(["intermediate", "proficient", "advanced", "expert"]); + return member.roles.cache.some(role => higher_skill_role_names.has(role.name.toLowerCase())); + } + private wrap_command_handler( handler: (command: TextBasedCommand, target: Discord.GuildMember, ...args: Args) => Promise, ) { @@ -296,6 +308,8 @@ export default class VoiceModeration extends BotComponent { res.upsertedCount > 0 && !member.roles.cache.has(this.wheatley.roles.voice.id) && !member.roles.cache.has(this.wheatley.roles.no_voice.id) && + !member.roles.cache.has(this.wheatley.roles.server_booster.id) && + !this.has_skill_role_above_beginner(member) && new_state.channel?.isVoiceBased() ) { await new_state.channel.send({ @@ -304,7 +318,7 @@ export default class VoiceModeration extends BotComponent { "new users are suppressed by default to protect our voice channels. " + "You will be able to speak when joining a channel with a voice moderator present. " + "Stick around and you will eventually be granted permanent voice access. " + - "Please do not ping voice moderators to be unsupressed.", + "Please do not ping voice moderators to be unsupressed or for the voice role.", allowedMentions: { users: [member.id] }, }); } From 95be28ddf9782d622ac29763ae58faed44e4a17a Mon Sep 17 00:00:00 2001 From: ianpike Date: Fri, 9 Jan 2026 17:18:50 -0500 Subject: [PATCH 3/4] Move vc auto reply logic into a tccpp component and apply requested changes --- .../components/voice-first-join-notice.ts | 102 ++++++++++++++++++ .../wheatley/components/moderation/voice.ts | 68 ------------ 2 files changed, 102 insertions(+), 68 deletions(-) create mode 100644 src/modules/tccpp/components/voice-first-join-notice.ts diff --git a/src/modules/tccpp/components/voice-first-join-notice.ts b/src/modules/tccpp/components/voice-first-join-notice.ts new file mode 100644 index 00000000..8f5d18d9 --- /dev/null +++ b/src/modules/tccpp/components/voice-first-join-notice.ts @@ -0,0 +1,102 @@ +import { strict as assert } from "assert"; +import * as Discord from "discord.js"; + +import { BotComponent } from "../../../bot-component.js"; +import SkillRoles, { SkillLevel } from "./skill-roles.js"; + +type voice_first_join_notice_entry = { + guild: string; + user: string; + first_seen_at: Date; + first_channel: string; +}; + +export default class VoiceFirstJoinNotice extends BotComponent { + private database = this.wheatley.database.create_proxy<{ + voice_first_join_notice: voice_first_join_notice_entry; + }>(); + + override async setup() { + await this.database.voice_first_join_notice.createIndex({ guild: 1, user: 1 }, { unique: true }); + } + + override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { + // user joined a voice channel + if (old_state.channelId != null || new_state.channelId == null) { + return; + } + + // ignore other guilds + if (new_state.guild.id !== this.wheatley.guild.id) { + return; + } + + // ignore bots + const member = new_state.member; + if (!member || member.user.bot) { + return; + } + + // ignore AFK + if (new_state.channelId === this.wheatley.guild.afkChannelId) { + return; + } + + // Record first join regardless of whether we'd send (so we never send later if roles change) + const res = await this.database.voice_first_join_notice.updateOne( + { guild: new_state.guild.id, user: member.id }, + { + $setOnInsert: { + guild: new_state.guild.id, + user: member.id, + first_seen_at: new Date(), + first_channel: new_state.channelId, + }, + }, + { upsert: true }, + ); + + // ignore if we already recorded this join + if (res.upsertedCount === 0) { + return; + } + + // Only send to users without permanent voice access / exceptions. + if (member.roles.cache.has(this.wheatley.roles.voice.id)) { + return; + } + + // ignore if the user has the no_voice role + if (member.roles.cache.has(this.wheatley.roles.no_voice.id)) { + return; + } + + // ignore if the user is a server booster + if (member.roles.cache.has(this.wheatley.roles.server_booster.id)) { + return; + } + + // ignore if the user has a skill level above beginner + const skill_roles_component = this.wheatley.components.get("SkillRoles"); + assert(skill_roles_component && skill_roles_component instanceof SkillRoles, "SkillRoles component missing"); + if (skill_roles_component.find_highest_skill_level(member) > SkillLevel.beginner) { + return; + } + + const channel = new_state.channel; + // The member joined a voice channel so this should always be non-null. + if (!channel) { + return; + } + + await channel.send({ + content: + `<@${member.id}> ` + + "new users are suppressed by default to protect our voice channels. " + + "You will be able to speak when joining a channel with a voice moderator present. " + + "Stick around and you will eventually be granted permanent voice access. " + + "__Please do not ping voice moderators to be unsupressed or for the voice role.__", + allowedMentions: { users: [member.id] }, + }); + } +} diff --git a/src/modules/wheatley/components/moderation/voice.ts b/src/modules/wheatley/components/moderation/voice.ts index 3ca3cbb5..46d16008 100644 --- a/src/modules/wheatley/components/moderation/voice.ts +++ b/src/modules/wheatley/components/moderation/voice.ts @@ -14,30 +14,16 @@ import { TextBasedCommand } from "../../../../command-abstractions/text-based-co import { SelfClearingSet } from "../../../../utils/containers.js"; import { build_description } from "../../../../utils/strings.js"; import { unwrap } from "../../../../utils/misc.js"; -import SkillRoles, { SkillLevel } from "../../../tccpp/components/skill-roles.js"; - -type voice_first_join_notice_entry = { - guild: string; - user: string; - first_seen_at: Date; - first_channel: string; -}; export default class VoiceModeration extends BotComponent { private recently_in_voice = new SelfClearingSet(5 * MINUTE); private staff_action_log!: Discord.TextChannel; private voice_hotline!: Discord.TextChannel; - private database = this.wheatley.database.create_proxy<{ - voice_first_join_notice: voice_first_join_notice_entry; - }>(); - override async setup(commands: CommandSetBuilder) { this.staff_action_log = await this.utilities.get_channel(this.wheatley.channels.staff_action_log); this.voice_hotline = await this.utilities.get_channel(this.wheatley.channels.voice_hotline); - await this.database.voice_first_join_notice.createIndex({ guild: 1, user: 1 }, { unique: true }); - commands.add( new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral) .set_category("Misc") @@ -118,17 +104,6 @@ export default class VoiceModeration extends BotComponent { ); } - private has_skill_role_above_beginner(member: Discord.GuildMember) { - const skill_roles_component = this.wheatley.components.get("SkillRoles"); - if (skill_roles_component && skill_roles_component instanceof SkillRoles) { - return skill_roles_component.find_highest_skill_level(member) > SkillLevel.beginner; - } - - // If the SkillRoles component isn't loaded, check by role name. - const higher_skill_role_names = new Set(["intermediate", "proficient", "advanced", "expert"]); - return member.roles.cache.some(role => higher_skill_role_names.has(role.name.toLowerCase())); - } - private wrap_command_handler( handler: (command: TextBasedCommand, target: Discord.GuildMember, ...args: Args) => Promise, ) { @@ -280,49 +255,6 @@ export default class VoiceModeration extends BotComponent { if (!new_state.channel && new_state.member) { this.recently_in_voice.insert(new_state.member.id); } - - // First-ever voice join notice for users without permanent voice access - if ( - old_state.channelId == null && - new_state.channelId != null && - new_state.guild.id === this.wheatley.guild.id && - new_state.member != null && - !new_state.member.user.bot && - new_state.channelId !== this.wheatley.guild.afkChannelId - ) { - const member = new_state.member; - const res = await this.database.voice_first_join_notice.updateOne( - { guild: new_state.guild.id, user: member.id }, - { - $setOnInsert: { - guild: new_state.guild.id, - user: member.id, - first_seen_at: new Date(), - first_channel: new_state.channelId, - }, - }, - { upsert: true }, - ); - - if ( - res.upsertedCount > 0 && - !member.roles.cache.has(this.wheatley.roles.voice.id) && - !member.roles.cache.has(this.wheatley.roles.no_voice.id) && - !member.roles.cache.has(this.wheatley.roles.server_booster.id) && - !this.has_skill_role_above_beginner(member) && - new_state.channel?.isVoiceBased() - ) { - await new_state.channel.send({ - content: - `<@${member.id}> ` + - "new users are suppressed by default to protect our voice channels. " + - "You will be able to speak when joining a channel with a voice moderator present. " + - "Stick around and you will eventually be granted permanent voice access. " + - "Please do not ping voice moderators to be unsupressed or for the voice role.", - allowedMentions: { users: [member.id] }, - }); - } - } } override async on_audit_log_entry_create(entry: Discord.GuildAuditLogsEntry): Promise { From 1b026b130e38b3e83b1c89287acf08db52d65cd8 Mon Sep 17 00:00:00 2001 From: Ian Pike Date: Sun, 11 Jan 2026 12:22:14 -0500 Subject: [PATCH 4/4] Remove redundant comments --- .../tccpp/components/voice-first-join-notice.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/modules/tccpp/components/voice-first-join-notice.ts b/src/modules/tccpp/components/voice-first-join-notice.ts index 8f5d18d9..09a06499 100644 --- a/src/modules/tccpp/components/voice-first-join-notice.ts +++ b/src/modules/tccpp/components/voice-first-join-notice.ts @@ -21,28 +21,23 @@ export default class VoiceFirstJoinNotice extends BotComponent { } override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { - // user joined a voice channel if (old_state.channelId != null || new_state.channelId == null) { return; } - // ignore other guilds if (new_state.guild.id !== this.wheatley.guild.id) { return; } - // ignore bots const member = new_state.member; if (!member || member.user.bot) { return; } - // ignore AFK if (new_state.channelId === this.wheatley.guild.afkChannelId) { return; } - // Record first join regardless of whether we'd send (so we never send later if roles change) const res = await this.database.voice_first_join_notice.updateOne( { guild: new_state.guild.id, user: member.id }, { @@ -56,27 +51,22 @@ export default class VoiceFirstJoinNotice extends BotComponent { { upsert: true }, ); - // ignore if we already recorded this join if (res.upsertedCount === 0) { return; } - // Only send to users without permanent voice access / exceptions. if (member.roles.cache.has(this.wheatley.roles.voice.id)) { return; } - // ignore if the user has the no_voice role if (member.roles.cache.has(this.wheatley.roles.no_voice.id)) { return; } - // ignore if the user is a server booster if (member.roles.cache.has(this.wheatley.roles.server_booster.id)) { return; } - // ignore if the user has a skill level above beginner const skill_roles_component = this.wheatley.components.get("SkillRoles"); assert(skill_roles_component && skill_roles_component instanceof SkillRoles, "SkillRoles component missing"); if (skill_roles_component.find_highest_skill_level(member) > SkillLevel.beginner) { @@ -84,7 +74,6 @@ export default class VoiceFirstJoinNotice extends BotComponent { } const channel = new_state.channel; - // The member joined a voice channel so this should always be non-null. if (!channel) { return; }