Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
.vscode
.idea
node_modules
Expand Down
50 changes: 35 additions & 15 deletions src/modules/tccpp/components/permissions-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SkillRoles from "./skill-roles.js";
import { named_id } from "../../../channel-map.js";
import { unwrap } from "../../../utils/misc.js";
import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js";
import { channel_has_member_with_role } from "../../../utils/discord.js";
import { set_timeout } from "../../../utils/node.js";

const categories_map = {
Expand Down Expand Up @@ -61,6 +62,7 @@ export default class PermissionManager extends BotComponent {
wheatley_roles.server_booster,
wheatley_roles.no_voice,
wheatley_roles.voice_moderator,
wheatley_roles.voice_muted,
wheatley_roles.no_suggestions,
wheatley_roles.no_suggestions_at_all,
wheatley_roles.jedi_council,
Expand Down Expand Up @@ -221,6 +223,9 @@ export default class PermissionManager extends BotComponent {
[this.roles.voice_moderator.id]: {
allow: [...acive_voice_permissions, SET_VOICE_STATUS_PERMISSION_BIT],
},
[this.roles.voice_muted.id]: {
deny: acive_voice_permissions,
},
};
const mod_only_channel: permission_overwrites = {
[this.wheatley.guild.roles.everyone.id]: {
Expand Down Expand Up @@ -487,13 +492,12 @@ export default class PermissionManager extends BotComponent {
await category.permissionOverwrites.set(
Object.entries(permissions).map(([id, permissions]) => ({ id, ...permissions })),
);
const channels = category.children.cache.map(channel => channel);
for (const channel of channels) {
for (const channel of category.children.cache.values()) {
await this.sync_channel_permissions(channel);
if (channel.isVoiceBased()) {
for (const [id, member] of channel.members) {
if (await this.wheatley.check_permissions(member, Discord.PermissionFlagsBits.MuteMembers)) {
await this.mod_has_entered_the_building(channel);
await this.mod_has_entered_the_building(channel, member.id);
break;
}
}
Expand All @@ -518,15 +522,31 @@ export default class PermissionManager extends BotComponent {
}, HOUR);
}

private async mod_has_entered_the_building(channel: Discord.Channel) {
assert(channel.isVoiceBased());
private get_base_permissions(
channel: Discord.VoiceChannel | Discord.StageChannel,
): permission_overwrites | undefined {
if (this.channel_overwrites[channel.id] != null) {
return this.channel_overwrites[channel.id];
}
if (channel.parent != null) {
return this.category_permissions[channel.parent.id];
}
return undefined;
}

private has_moderator_in_channel(channel: Discord.VoiceChannel | Discord.StageChannel): boolean {
return channel.members.some(member => member.permissions.has(Discord.PermissionFlagsBits.MuteMembers));
}

private async mod_has_entered_the_building(
channel: Discord.VoiceChannel | Discord.StageChannel,
entering_moderator_id: string,
) {
if (channel.id in this.dynamic_channel_overwrites || channel.id == this.wheatley.guild.afkChannelId) {
return;
}
const everyone = this.wheatley.guild.roles.everyone.id;
const base_perms =
this.channel_overwrites[channel.id] ??
(channel.parent ? this.category_permissions[channel.parent.id] : undefined);
const base_perms = this.get_base_permissions(channel);
if (
!base_perms ||
!base_perms[everyone] ||
Expand All @@ -536,6 +556,9 @@ export default class PermissionManager extends BotComponent {
) {
return;
}
if (channel_has_member_with_role(channel, this.roles.voice_moderator.id, entering_moderator_id)) {
return;
}
const perms = Object.assign({}, base_perms);
perms[everyone] = {
allow: [
Expand All @@ -549,15 +572,12 @@ export default class PermissionManager extends BotComponent {
await this.sync_channel_permissions(channel);
}

private async mod_has_left_the_building(channel: Discord.Channel) {
assert(channel.isVoiceBased());
private async mod_has_left_the_building(channel: Discord.VoiceChannel | Discord.StageChannel) {
if (!(channel.id in this.dynamic_channel_overwrites)) {
return;
}
for (const [id, member] of channel.members) {
if (await this.wheatley.check_permissions(member, Discord.PermissionFlagsBits.MuteMembers)) {
return;
}
if (this.has_moderator_in_channel(channel)) {
return;
}
delete this.dynamic_channel_overwrites[channel.id];
await this.sync_channel_permissions(channel);
Expand All @@ -576,7 +596,7 @@ export default class PermissionManager extends BotComponent {
await this.mod_has_left_the_building(old_state.channel);
}
if (new_state.channel) {
await this.mod_has_entered_the_building(new_state.channel);
await this.mod_has_entered_the_building(new_state.channel, new_state.member.id);
}
}
}
Expand Down
56 changes: 46 additions & 10 deletions src/modules/wheatley/components/moderation/voice-mute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import * as mongo from "mongodb";
import { strict as assert } from "assert";

import { M } from "../../../../utils/debugging-and-logging.js";
import { ModerationComponent } from "./moderation-common.js";
import { ModerationComponent, duration_regex } from "./moderation-common.js";
import { role_map } from "../../../../role-map.js";
import { wheatley_roles } from "../../roles.js";
import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js";
import {
EarlyReplyMode,
Expand All @@ -14,6 +16,8 @@ import { TextBasedCommand } from "../../../../command-abstractions/text-based-co
import { moderation_entry, basic_moderation_with_user } from "./schemata.js";

export default class VoiceMute extends ModerationComponent {
private readonly roles = role_map(this.wheatley, wheatley_roles.voice_muted);

get type() {
return "voice_mute" as const;
}
Expand All @@ -22,33 +26,49 @@ export default class VoiceMute extends ModerationComponent {
return "voice muted";
}

override get persist_moderation() {
return true;
}

override async setup(commands: CommandSetBuilder) {
await super.setup(commands);
this.roles.resolve();

commands.add(
new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral)
.set_description("Voice moderation")
.set_permissions(Discord.PermissionFlagsBits.MuteMembers)
.add_subcommand(
new TextBasedCommandBuilder("mute", EarlyReplyMode.ephemeral)
.set_description("Server mute a user")
.set_description("Voice mute a user")
.add_user_option({
title: "user",
description: "User to mute",
required: true,
})
.add_string_option({
title: "duration",
description: "Duration",
regex: duration_regex,
required: false,
})
.add_string_option({
title: "reason",
description: "Reason",
required: false,
})
.set_handler((command: TextBasedCommand, user: Discord.User, reason: string | null) =>
this.moderation_issue_handler(command, user, null, reason, { type: this.type }),
.set_handler(
(
command: TextBasedCommand,
user: Discord.User,
duration: string | null,
reason: string | null,
) => this.moderation_issue_handler(command, user, duration, reason, { type: this.type }),
),
)
.add_subcommand(
new TextBasedCommandBuilder("unmute", EarlyReplyMode.ephemeral)
.set_description("Server unmute a user")
.set_description("Voice unmute a user")
.add_user_option({
title: "user",
description: "User to unmute",
Expand All @@ -64,25 +84,41 @@ export default class VoiceMute extends ModerationComponent {
);
}

private async refresh_voice_permissions(member: Discord.GuildMember) {
try {
await this.wheatley.force_voice_permissions_update(member);
} catch (e) {
M.error(`Failed to refresh voice permissions for ${member.user.tag}`, e);
}
}

async apply_moderation(entry: moderation_entry) {
M.info(`Applying voice mute to ${entry.user_name}`);
if (this.dummy_rounds) {
return;
}
const member = await this.wheatley.try_fetch_guild_member(entry.user);
if (member?.voice.channel) {
await member.voice.setMute(true);
if (member) {
await member.roles.add(this.roles.voice_muted);
await this.refresh_voice_permissions(member);
}
}

async remove_moderation(entry: mongo.WithId<moderation_entry>) {
M.info(`Removing voice mute from ${entry.user_name}`);
if (this.dummy_rounds) {
return;
}
const member = await this.wheatley.try_fetch_guild_member(entry.user);
if (member?.voice.channel) {
await member.voice.setMute(false);
if (member) {
await member.roles.remove(this.roles.voice_muted);
await this.refresh_voice_permissions(member);
}
}

async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) {
assert(moderation.type == this.type);
const member = await this.wheatley.try_fetch_guild_member(moderation.user);
return member?.voice.serverMute ?? false;
return member?.roles.cache.has(this.roles.voice_muted.id) ?? false;
}
}
24 changes: 23 additions & 1 deletion src/modules/wheatley/components/moderation/voice-take.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
} from "../../../../command-abstractions/text-based-command-builder.js";
import { TextBasedCommand } from "../../../../command-abstractions/text-based-command.js";
import { moderation_entry, basic_moderation_with_user } from "./schemata.js";
import { channel_has_member_with_role } from "../../../../utils/discord.js";

export default class VoiceTake extends ModerationComponent {
private roles = role_map(this.wheatley, wheatley_roles.voice);
private readonly roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator);

get type() {
return "voice_take" as const;
Expand Down Expand Up @@ -97,24 +98,45 @@ export default class VoiceTake extends ModerationComponent {
await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true });
}

private async refresh_voice_permissions(member: Discord.GuildMember) {
const channel = member.voice.channel;
if (!channel || channel_has_member_with_role(channel, this.roles.voice_moderator.id)) {
return;
}
try {
await this.wheatley.force_voice_permissions_update(member);
} catch (e) {
M.error(`Failed to refresh voice permissions for ${member.user.tag}`, e);
}
}

async apply_moderation(entry: moderation_entry) {
M.info(`Applying voice take to ${entry.user_name}`);
if (this.dummy_rounds) {
return;
}
const member = await this.wheatley.try_fetch_guild_member(entry.user);
if (member) {
await member.roles.remove(this.roles.voice);
await this.refresh_voice_permissions(member);
}
}

async remove_moderation(entry: mongo.WithId<moderation_entry>) {
M.info(`Removing voice take from ${entry.user_name}`);
if (this.dummy_rounds) {
return;
}
const member = await this.wheatley.try_fetch_guild_member(entry.user);
if (member) {
await member.roles.add(this.roles.voice);
await this.refresh_voice_permissions(member);
}
}

override async apply_revoke_to_discord(member: Discord.GuildMember): Promise<void> {
await member.roles.add(this.roles.voice);
await this.refresh_voice_permissions(member);
}

async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) {
Expand Down
96 changes: 96 additions & 0 deletions src/modules/wheatley/components/moderation/voice-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as Discord from "discord.js";

import { BotComponent } from "../../../../bot-component.js";
import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js";
import {
EarlyReplyMode,
TextBasedCommandBuilder,
} from "../../../../command-abstractions/text-based-command-builder.js";
import { TextBasedCommand } from "../../../../command-abstractions/text-based-command.js";
import { wheatley_roles } from "../../roles.js";
import { create_error_reply } from "../../../../wheatley.js";
import {
perform_voice_update,
select_everyone,
exclude_bots,
select_without_role,
type VoiceUpdateContext,
} from "../../../../utils/voice-update.js";

export default class VoiceUpdate extends BotComponent {
static override get is_freestanding() {
return true;
}

override async setup(commands: CommandSetBuilder) {
commands.add(
new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral)
.set_description("Voice moderation")
.set_permissions(Discord.PermissionFlagsBits.MuteMembers)
.add_subcommand(
new TextBasedCommandBuilder("update", EarlyReplyMode.ephemeral)
.set_description("Force-refresh voice permissions in your current channel")
.add_boolean_option({
title: "all",
description: "Refresh everyone rather than only those without the voice role",
required: false,
})
.set_handler(this.handle_update.bind(this)),
),
);
}

private async handle_update(command: TextBasedCommand, all: boolean | null) {
const member = await command.get_member();
if (!member.permissions.has(Discord.PermissionFlagsBits.MoveMembers)) {
await command.reply(create_error_reply("You need the Move Members permission to use this command."));
return;
}

const channel = member.voice.channel;
if (!channel?.isVoiceBased()) {
await command.reply(create_error_reply("You must be in a voice channel to use this command."));
return;
}

let selector = exclude_bots(select_everyone);
if (!all) {
let voice_role: Discord.Role;
try {
voice_role = this.utilities.resolve_role(wheatley_roles.voice);
} catch {
await command.reply(
create_error_reply(
"Could not resolve the `voice` role needed for affected-user refresh. " +
"Create that role in this guild or run with `all: true`.",
),
);
return;
}
selector = exclude_bots(select_without_role(voice_role.id));
}

const context: VoiceUpdateContext = {
caller: member,
channel,
wheatley: this.wheatley,
};
const result = await perform_voice_update(context, selector);
if (result.afk_missing) {
await command.reply(
create_error_reply("No AFK channel is configured for this guild, so voice refresh cannot run."),
);
return;
}

const scope = all ? "" : " affected";
const skipped_suffix = result.skipped > 0 ? ` (${result.skipped} skipped)` : "";
await command.reply({
content:
`Refreshed voice permissions for ${result.succeeded}${scope} member(s) in ${channel.name}.` +
skipped_suffix +
(result.failed > 0 ? ` (${result.failed} failed)` : ""),
should_text_reply: true,
});
}
}
Loading
Loading