Skip to content
Closed
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
52 changes: 42 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 @@ -66,23 +86,35 @@ export default class VoiceMute extends ModerationComponent {

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);
if (member.voice.channel) {
await this.wheatley.force_voice_permissions_update(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);
if (member.voice.channel) {
await this.wheatley.force_voice_permissions_update(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;
}
}
84 changes: 83 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,17 @@ 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";
import {
perform_voice_update,
select_everyone,
exclude_bots,
select_without_role,
type VoiceUpdateContext,
} from "../../../../utils/voice-update.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 @@ -62,6 +70,17 @@ export default class VoiceTake extends ModerationComponent {
required: true,
})
.set_handler(this.handle_give.bind(this)),
)
.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 (required on non-TCCPP). Omit on TCCPP for affected users only.",
required: false,
})
.set_handler(this.handle_update.bind(this)),
),
);
}
Expand Down Expand Up @@ -97,24 +116,87 @@ export default class VoiceTake extends ModerationComponent {
await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true });
}

private async handle_update(command: TextBasedCommand, all: boolean | null) {
const member = await command.get_member();
if (!member.permissions.has(Discord.PermissionFlagsBits.MoveMembers)) {
await this.reply_with_error(command, "You need the Move Members permission to use this command.");
return;
}
const channel = member.voice.channel;
if (!channel?.isVoiceBased()) {
await this.reply_with_error(command, "You must be in a voice channel to use this command.");
return;
}
const on_tccpp = this.wheatley.components.has("PermissionManager");
if (!all && !on_tccpp) {
await this.reply_with_error(
command,
"Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)",
);
return;
}
const context: VoiceUpdateContext = {
guild: this.wheatley.guild,
caller: member,
channel,
wheatley: this.wheatley,
};
const selector = all ? exclude_bots(select_everyone) : exclude_bots(select_without_role(this.roles.voice.id));
const result = await perform_voice_update(context, selector);
if (result.afk_missing) {
await this.reply_with_error(
command,
"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,
});
}

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);
const channel = member.voice.channel;
if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) {
await this.wheatley.force_voice_permissions_update(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);
const channel = member.voice.channel;
if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) {
await this.wheatley.force_voice_permissions_update(member);
}
}
}

override async apply_revoke_to_discord(member: Discord.GuildMember): Promise<void> {
await member.roles.add(this.roles.voice);
const channel = member.voice.channel;
if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) {
await this.wheatley.force_voice_permissions_update(member);
}
}

async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) {
Expand Down
1 change: 1 addition & 0 deletions src/modules/wheatley/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const wheatley_roles = define_roles({
herald: { id: "1095555811536797787", name: "Herald" },
linked_github: { id: "1080596526478397471", name: "Linked GitHub" },
voice: { id: "1368073548983308328", name: "voice" },
voice_muted: { id: "1479764785917595822", name: "Voice Muted" },
});
8 changes: 8 additions & 0 deletions src/utils/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export function textchannelify(x: Discord.Channel): Discord.TextBasedChannel {
return x;
}

export function channel_has_member_with_role(
channel: Discord.VoiceChannel | Discord.StageChannel,
role_id: string,
excluded_member_id?: string,
): boolean {
return channel.members.some(member => member.id !== excluded_member_id && member.roles.cache.has(role_id));
}

export function get_tag(channel: Discord.ForumChannel, name: string) {
const candidates = channel.availableTags.filter(tag => tag.name == name);
assert(
Expand Down
Loading
Loading