Skip to content

Commit 53869e6

Browse files
committed
Overhaul voice moderation: Add voice mutes, add voice notes, remove voice quarantine, allow voice mods to pull up mod logs, and record voice take in the moderation system
1 parent 304e054 commit 53869e6

10 files changed

Lines changed: 390 additions & 328 deletions

File tree

src/modules/wheatley/components/moderation/moderation-common.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
moderation_entry,
2828
basic_moderation_with_user,
2929
basic_moderation,
30+
note_moderation_types,
3031
} from "./schemata.js";
3132
import { set_interval } from "../../../../utils/node.js";
3233

@@ -76,6 +77,10 @@ export class ParseError extends Error {
7677
}
7778
}
7879

80+
export type revoke_handler_options = {
81+
allow_no_entry?: boolean;
82+
};
83+
7984
// Returns the corresponding duration in milliseconds,
8085
// or null for permanent duration.
8186
// Throws ParseError when parsing fails.
@@ -145,7 +150,7 @@ export abstract class ModerationComponent extends BotComponent {
145150
sleep_list: SleepList<mongo.WithId<moderation_entry>, mongo.BSON.ObjectId>;
146151
timer: NodeJS.Timer | null = null;
147152

148-
static non_duration_moderation_set = new Set(["warn", "kick", "softban", "note"]);
153+
static non_duration_moderation_set = new Set(["warn", "kick", "softban", "note", "voice_note"]);
149154

150155
static moderations_count = new PromClient.Gauge({
151156
name: "tccpp_moderations_count",
@@ -306,6 +311,12 @@ export abstract class ModerationComponent extends BotComponent {
306311
// Check if the moderation is currently applied in Discord (role present, ban in place, etc.)
307312
abstract is_moderation_applied_in_discord(moderation: basic_moderation_with_user): Promise<boolean>;
308313

314+
// Apply revoke action to Discord without an existing moderation entry.
315+
// Must be overridden in subclasses that use allow_no_entry mode.
316+
async apply_revoke_to_discord(_member: Discord.GuildMember): Promise<void> {
317+
throw new Error("apply_revoke_to_discord must be overridden when using allow_no_entry");
318+
}
319+
309320
// Check if there are other active moderations of the same type for this user (excluding the given entry)
310321
async has_other_active_moderations(entry: mongo.WithId<moderation_entry>): Promise<boolean> {
311322
const query: mongo.Filter<moderation_entry> = {
@@ -576,7 +587,7 @@ export abstract class ModerationComponent extends BotComponent {
576587
];
577588
}
578589
this.staff_action_log.send(message_options).catch(this.wheatley.critical_error.bind(this.wheatley));
579-
if (moderation.type !== "note") {
590+
if (!note_moderation_types.includes(moderation.type)) {
580591
this.public_action_log
581592
.send({
582593
embeds: [
@@ -727,8 +738,24 @@ export abstract class ModerationComponent extends BotComponent {
727738
expunged: null,
728739
link: command.get_or_forge_url(),
729740
};
730-
const cant_dm = !(await this.notify_user(user, this.past_participle, moderation));
741+
const is_note_type = note_moderation_types.includes(this.type);
742+
const notification_failed = await (async () => {
743+
if (!is_note_type) {
744+
return !(await this.notify_user(user, this.past_participle, moderation));
745+
} else {
746+
return false;
747+
}
748+
})();
731749
await this.issue_moderation(moderation);
750+
const success_message = is_note_type
751+
? `Note added for ${user.displayName}`
752+
: `${user.displayName} was ${this.past_participle}`;
753+
const reason_line = (() => {
754+
if (!command.is_slash() || !reason) {
755+
return null;
756+
}
757+
return is_note_type ? `**Note:** ${reason}` : `**Reason:** ${reason}`;
758+
})();
732759
await command.reply({
733760
content:
734761
basic_moderation_info.type === "ban"
@@ -739,8 +766,8 @@ export abstract class ModerationComponent extends BotComponent {
739766
.setColor(colors.wheatley)
740767
.setDescription(
741768
build_description(
742-
`${this.wheatley.emoji.success} ***${user.displayName} was ${this.past_participle}***`,
743-
command.is_slash() && reason ? `**Reason:** ${reason}` : null,
769+
`${this.wheatley.emoji.success} ***${success_message}***`,
770+
reason_line,
744771
(!this.is_once_off && duration_string === null) || reason === null
745772
? `Remember to provide a ${[
746773
!this.is_once_off && duration_string === null ? "duration" : null,
@@ -752,13 +779,16 @@ export abstract class ModerationComponent extends BotComponent {
752779
!this.is_once_off && duration_string !== null
753780
? `**Duration**: ${duration == null ? "permanent" : time_to_human(duration)}`
754781
: null,
755-
cant_dm ? "Note: Couldn't notify user (DM and thread fallback both failed)." : null,
782+
notification_failed
783+
? "Note: Couldn't notify user (DM and thread fallback both failed)."
784+
: null,
756785
),
757786
)
758787
.setFooter({
759788
text: `Case ${moderation.case_number}`,
760789
}),
761790
],
791+
ephemeral_if_possible: is_note_type,
762792
});
763793
} catch (e) {
764794
if (e instanceof ParseError) {
@@ -841,6 +871,7 @@ export abstract class ModerationComponent extends BotComponent {
841871
user: Discord.User,
842872
reason: string | null,
843873
additional_moderation_properties: any = {},
874+
options: revoke_handler_options = {},
844875
) {
845876
assert(!this.is_once_off);
846877
try {
@@ -862,7 +893,17 @@ export abstract class ModerationComponent extends BotComponent {
862893
sort: { issued_at: -1 },
863894
},
864895
);
865-
if (!res) {
896+
if (!res && options.allow_no_entry) {
897+
const member = await this.wheatley.try_fetch_guild_member(user);
898+
if (member) {
899+
await this.apply_revoke_to_discord(member);
900+
}
901+
const message =
902+
`${this.wheatley.emoji.success} ` + `***${user.displayName} was un${this.past_participle}***`;
903+
await command.reply({
904+
embeds: [new Discord.EmbedBuilder().setColor(colors.wheatley).setDescription(message)],
905+
});
906+
} else if (!res) {
866907
await this.reply_with_error(command, `User is not ${this.past_participle}`);
867908
} else {
868909
this.sleep_list.remove(res._id);
@@ -904,7 +945,7 @@ export abstract class ModerationComponent extends BotComponent {
904945
.setDescription(remaining_message),
905946
],
906947
});
907-
if (res.type !== "note") {
948+
if (!note_moderation_types.includes(res.type)) {
908949
await this.public_action_log.send({
909950
embeds: [
910951
Modlogs.case_summary(

src/modules/wheatley/components/moderation/moderation-control.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { M } from "../../../../utils/debugging-and-logging.js";
77
import { BotComponent } from "../../../../bot-component.js";
88
import { Wheatley } from "../../../../wheatley.js";
99
import { ModerationComponent, parse_duration } from "./moderation-common.js";
10-
import { moderation_entry } from "./schemata.js";
10+
import { moderation_entry, note_moderation_types, voice_moderation_types } from "./schemata.js";
1111
import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js";
1212
import { colors } from "../../../../common.js";
1313
import Modlogs, { staff_moderation_display_options, public_moderation_display_options } from "./modlogs.js";
@@ -102,7 +102,7 @@ export default class ModerationControl extends BotComponent {
102102
new TextBasedCommandBuilder("expunge", EarlyReplyMode.visible)
103103
.set_category("Moderation")
104104
.set_description("Expunge a case")
105-
.set_permissions(Discord.PermissionFlagsBits.BanMembers)
105+
.set_permissions(Discord.PermissionFlagsBits.BanMembers | Discord.PermissionFlagsBits.MuteMembers)
106106
.add_number_option({
107107
title: "case",
108108
description: "Case to expunge",
@@ -157,7 +157,7 @@ export default class ModerationControl extends BotComponent {
157157
).setTitle(`Case ${res.case_number} reason updated`),
158158
],
159159
});
160-
if (res.type !== "note") {
160+
if (!note_moderation_types.includes(res.type)) {
161161
await this.public_action_log.send({
162162
embeds: [
163163
Modlogs.case_summary(
@@ -237,7 +237,7 @@ export default class ModerationControl extends BotComponent {
237237
).setTitle(`Case ${res.case_number} duration updated`),
238238
],
239239
});
240-
if (res.type !== "note") {
240+
if (!note_moderation_types.includes(res.type)) {
241241
await this.public_action_log.send({
242242
embeds: [
243243
Modlogs.case_summary(
@@ -254,14 +254,27 @@ export default class ModerationControl extends BotComponent {
254254
}
255255

256256
async expunge(command: TextBasedCommand, case_number: number, reason: string | null) {
257+
const member = await command.get_member();
258+
const has_ban_members = member.permissions.has(Discord.PermissionFlagsBits.BanMembers);
259+
if (!has_ban_members) {
260+
const moderation = await this.database.moderations.findOne({ case_number });
261+
if (!moderation) {
262+
await this.reply_with_error(command, `Case ${case_number} not found`);
263+
return;
264+
}
265+
if (!voice_moderation_types.includes(moderation.type)) {
266+
await this.reply_with_error(command, "You can only expunge voice moderation cases");
267+
return;
268+
}
269+
}
257270
const res = await this.database.moderations.findOneAndUpdate(
258271
{ case_number },
259272
{
260273
$set: {
261274
active: false, // moderation update handler will handle the removal if necessary
262275
expunged: {
263276
moderator: command.user.id,
264-
moderator_name: (await command.get_member()).displayName,
277+
moderator_name: member.displayName,
265278
reason,
266279
timestamp: Date.now(),
267280
},
@@ -284,7 +297,7 @@ export default class ModerationControl extends BotComponent {
284297
).setTitle(`Case ${res.case_number} expunged`),
285298
],
286299
});
287-
if (res.type !== "note") {
300+
if (!note_moderation_types.includes(res.type)) {
288301
await this.public_action_log.send({
289302
embeds: [
290303
Modlogs.case_summary(

src/modules/wheatley/components/moderation/modlogs.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
TextBasedCommand,
2121
} from "../../../../command-abstractions/text-based-command.js";
2222
import { remove } from "../../../../utils/arrays.js";
23-
import { moderation_entry } from "./schemata.js";
23+
import { moderation_entry, voice_moderation_types } from "./schemata.js";
2424
import { discord_timestamp } from "../../../../utils/discord.js";
2525
import { unwrap } from "../../../../utils/misc.js";
2626
import LinkedAccounts from "../linked-accounts.js";
@@ -114,9 +114,11 @@ export default class Modlogs extends BotComponent {
114114
moderation: moderation_entry,
115115
): modlog_display_options {
116116
const is_mod_only = Modlogs.is_mod_only(channel);
117+
const is_voice_mod_channel = Modlogs.is_voice_mod_channel(channel);
118+
const is_voice_moderation = voice_moderation_types.includes(moderation.type);
117119
return {
118120
show_private_logs: is_mod_only,
119-
show_moderator: is_mod_only,
121+
show_moderator: is_mod_only || (is_voice_mod_channel && is_voice_moderation),
120122
};
121123
}
122124

src/modules/wheatley/components/moderation/note.ts

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
import { strict as assert } from "assert";
2-
31
import * as Discord from "discord.js";
42
import * as mongo from "mongodb";
53

6-
import { Wheatley } from "../../../../wheatley.js";
4+
import { strict as assert } from "assert";
5+
76
import { ModerationComponent } from "./moderation-common.js";
87
import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js";
98
import {
109
EarlyReplyMode,
1110
TextBasedCommandBuilder,
1211
} from "../../../../command-abstractions/text-based-command-builder.js";
1312
import { TextBasedCommand } from "../../../../command-abstractions/text-based-command.js";
14-
import { moderation_entry, basic_moderation } from "./schemata.js";
15-
import { colors } from "../../../../common.js";
16-
import { build_description } from "../../../../utils/strings.js";
13+
import { moderation_entry, basic_moderation_with_user } from "./schemata.js";
1714

1815
export default class Note extends ModerationComponent {
1916
get type() {
@@ -52,54 +49,7 @@ export default class Note extends ModerationComponent {
5249
);
5350
}
5451

55-
override async moderation_issue_handler(
56-
command: TextBasedCommand,
57-
user: Discord.User,
58-
duration: string | null,
59-
reason: string | null,
60-
basic_moderation_info: basic_moderation,
61-
) {
62-
try {
63-
const moderation: moderation_entry = {
64-
...basic_moderation_info,
65-
case_number: -1,
66-
user: user.id,
67-
user_name: user.displayName,
68-
moderator: command.user.id,
69-
moderator_name: (await command.get_member()).displayName,
70-
reason,
71-
issued_at: Date.now(),
72-
duration: null,
73-
active: false,
74-
removed: null,
75-
expunged: null,
76-
link: command.get_or_forge_url(),
77-
};
78-
await this.issue_moderation(moderation);
79-
await command.reply({
80-
embeds: [
81-
new Discord.EmbedBuilder()
82-
.setColor(colors.wheatley)
83-
.setDescription(
84-
build_description(
85-
`${this.wheatley.emoji.success} ***Note added for ${user.displayName}***`,
86-
command.is_slash() && reason ? `**Reason:** ${reason}` : null,
87-
),
88-
)
89-
.setFooter({
90-
text: `Case ${moderation.case_number}`,
91-
}),
92-
],
93-
ephemeral_if_possible: true,
94-
});
95-
} catch (e) {
96-
await this.reply_with_error(command, `Error issuing ${this.type}`);
97-
this.wheatley.critical_error(e);
98-
}
99-
}
100-
10152
async apply_moderation(entry: moderation_entry) {
102-
// nop
10353
void entry;
10454
}
10555

@@ -108,7 +58,7 @@ export default class Note extends ModerationComponent {
10858
assert(false);
10959
}
11060

111-
is_moderation_applied_in_discord(moderation: basic_moderation): never {
61+
is_moderation_applied_in_discord(moderation: basic_moderation_with_user): never {
11262
void moderation;
11363
assert(false);
11464
}

src/modules/wheatley/components/moderation/schemata.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,22 @@ export type moderation_state = {
44
modmail_id: number;
55
};
66

7-
export type moderation_type = "mute" | "warn" | "ban" | "kick" | "rolepersist" | "timeout" | "softban" | "note";
7+
export type moderation_type =
8+
| "mute"
9+
| "warn"
10+
| "ban"
11+
| "kick"
12+
| "rolepersist"
13+
| "timeout"
14+
| "softban"
15+
| "note"
16+
| "voice_note"
17+
| "voice_mute"
18+
| "voice_take";
19+
20+
export const voice_moderation_types: moderation_type[] = ["voice_note", "voice_mute", "voice_take"];
21+
22+
export const note_moderation_types: moderation_type[] = ["note", "voice_note"];
823

924
export type moderation_edit_info = {
1025
moderator: string;

0 commit comments

Comments
 (0)