Skip to content

Commit

Permalink
feat: add timeout support (#2594)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet authored Mar 31, 2024
1 parent ec20a9c commit 030470d
Show file tree
Hide file tree
Showing 36 changed files with 715 additions and 232 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"#lib/discord": "./dist/lib/discord/index.js",
"#lib/moderation/actions": "./dist/lib/moderation/actions/index.js",
"#lib/moderation/common": "./dist/lib/moderation/common/index.js",
"#lib/moderation/managers/loggers": "./dist/lib/moderation/managers/loggers/index.js",
"#lib/moderation/managers": "./dist/lib/moderation/managers/index.js",
"#lib/moderation/workers": "./dist/lib/moderation/workers/index.js",
"#lib/moderation": "./dist/lib/moderation/index.js",
Expand Down
3 changes: 2 additions & 1 deletion scripts/SetMigrations.sql
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ VALUES
(1707505580449, 'V75SplitModerationType1707505580449'),
(1707558132765, 'V76AddOptOutUnknownMessageLogging1707558132765'),
(1707605222927, 'V77AddEventsIncludeBots1707605222927'),
(1707642380524, 'V78AddVoiceActivityLogging1707642380524');
(1707642380524, 'V78AddVoiceActivityLogging1707642380524'),
(1708164874479, 'V79AddTimeout1708164874479');

COMMIT;
22 changes: 13 additions & 9 deletions src/commands/Moderation/Utilities/case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ModerationManager, getAction } from '#lib/moderation';
import { getEmbed, getTitle, getTranslationKey } from '#lib/moderation/common';
import { SkyraCommand, SkyraSubcommand } from '#lib/structures';
import { PermissionLevels, type GuildMessage } from '#lib/types';
import { desc, minutes, seconds, years } from '#utils/common';
import { desc, minutes, seconds } from '#utils/common';
import { BrandingColors, Emojis } from '#utils/constants';
import { getModeration } from '#utils/functions';
import { TypeVariation } from '#utils/moderationConstants';
Expand Down Expand Up @@ -131,7 +131,7 @@ export class UserCommand extends SkyraSubcommand {
public async chatInputRunEdit(interaction: SkyraSubcommand.Interaction) {
const entry = await this.#getCase(interaction, true);
const reason = interaction.options.getString('reason');
const duration = this.#getDuration(interaction);
const duration = this.#getDuration(interaction, entry);

const moderation = getModeration(interaction.guild);
const t = getSupportedUserLanguageT(interaction);
Expand Down Expand Up @@ -300,15 +300,19 @@ export class UserCommand extends SkyraSubcommand {
return entries.sort((a, b) => desc(a.id, b.id));
}

#getDuration(interaction: SkyraSubcommand.Interaction, required: true): number;
#getDuration(interaction: SkyraSubcommand.Interaction, required?: false): number | null;
#getDuration(interaction: SkyraSubcommand.Interaction, required?: boolean) {
const parameter = interaction.options.getString('duration', required);
if (isNullish(parameter)) return null;
#getDuration(interaction: SkyraSubcommand.Interaction, entry: ModerationManager.Entry) {
const parameter = interaction.options.getString('duration');
if (isNullishOrEmpty(parameter)) return null;

return resolveTimeSpan(parameter, { minimum: 0, maximum: years(1) }) //
const action = getAction(entry.type);
if (action.durationExternal) {
const t = getSupportedUserLanguageT(interaction);
throw t(Root.TimeEditNotSupported, { type: t(getTranslationKey(entry.type)) });
}

return resolveTimeSpan(parameter, { minimum: action.minimumDuration, maximum: action.maximumDuration }) //
.mapErr((key) => getSupportedUserLanguageT(interaction)(key, { parameter: parameter.toString() }))
.unwrap();
.unwrapRaw();
}

async #getCase(interaction: SkyraSubcommand.Interaction, required: true): Promise<ModerationManager.Entry>;
Expand Down
24 changes: 24 additions & 0 deletions src/commands/Moderation/timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { LanguageKeys } from '#lib/i18n/languageKeys';
import { ModerationCommand } from '#lib/moderation';
import type { GuildMessage } from '#lib/types';
import { TypeVariation } from '#utils/moderationConstants';
import { ApplyOptions } from '@sapphire/decorators';
import { PermissionFlagsBits } from 'discord.js';

type Type = TypeVariation.Timeout;
type ValueType = null;

@ApplyOptions<ModerationCommand.Options<Type>>({
description: LanguageKeys.Commands.Moderation.TimeoutApplyDescription,
detailedDescription: LanguageKeys.Commands.Moderation.TimeoutApplyExtended,
requiredClientPermissions: [PermissionFlagsBits.ModerateMembers],
requiredMember: true,
type: TypeVariation.Timeout
})
export class UserModerationCommand extends ModerationCommand<Type, ValueType> {
protected override async checkTargetCanBeModerated(message: GuildMessage, context: ModerationCommand.HandlerParameters<ValueType>) {
const member = await super.checkTargetCanBeModerated(message, context);
if (member && !member.moderatable) throw context.args.t(LanguageKeys.Commands.Moderation.TimeoutNotModeratable);
return member;
}
}
2 changes: 1 addition & 1 deletion src/commands/Moderation/unban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ export class UserModerationCommand extends ModerationCommand<Type, ValueType> {
}

public override postHandle(_message: GuildMessage, { preHandled }: ModerationCommand.PostHandleParameters<ValueType>) {
preHandled?.unlock?.();
preHandled?.unlock();
}
}
18 changes: 18 additions & 0 deletions src/commands/Moderation/untimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { LanguageKeys } from '#lib/i18n/languageKeys';
import { ModerationCommand } from '#lib/moderation';
import { TypeVariation } from '#utils/moderationConstants';
import { ApplyOptions } from '@sapphire/decorators';
import { PermissionFlagsBits } from 'discord.js';

type Type = TypeVariation.Timeout;
type ValueType = null;

@ApplyOptions<ModerationCommand.Options<Type>>({
description: LanguageKeys.Commands.Moderation.TimeoutUndoDescription,
detailedDescription: LanguageKeys.Commands.Moderation.TimeoutUndoExtended,
requiredClientPermissions: [PermissionFlagsBits.ModerateMembers],
requiredMember: true,
isUndoAction: true,
type: TypeVariation.Timeout
})
export class UserModerationCommand extends ModerationCommand<Type, ValueType> {}
1 change: 1 addition & 0 deletions src/languages/en-US/commands/case.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"optionsShowDescription": "Whether or not to show the response publicly.",
"timeNotAllowed": "The type of the moderation case (**{{type}}**) does not allow for a duration.",
"timeNotAllowedInCompletedEntries": "The moderation case `{{caseId}}` has already been completed and cannot be edited.",
"timeEditNotSupported": "The type of the moderation case (**{{type}}**) does not allow editing the duration.",
"timeTooEarly": "The duration of the moderation case would end before it starts ({{time}}). The duration starts at {{start}}.",
"listEmpty": "There are no moderation cases with the selected filters.",
"listDetailsTitle_one": "There is 1 entry.",
Expand Down
57 changes: 52 additions & 5 deletions src/languages/en-US/commands/moderation.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"The amount of time worth of messages to delete, each flag is optional and can be combined. Up to 7 days."
]
],
"extendedHelp": "This command requires **{{BAN_MEMBERS, permissions}}**, and only members with lower role hierarchy position can be banned by me.\nNo, the server's owner cannot be banned.\nThis action can be optionally timed to create a temporary ban.",
"extendedHelp": "This command requires **{{BanMembers, permissions}}**, and only members with lower role hierarchy position can be banned by me.\nNo, the server's owner cannot be banned.\nThis action can be optionally timed to create a temporary ban.",
"examples": [
"@Pete",
"@Pete Spamming all channels.",
Expand All @@ -92,7 +92,7 @@
"dehoistDescription": "Shoot everyone with the Dehoistinator 3000",
"dehoistExtended": {
"extendedHelp": "The act of hoisting involves adding special characters in front of your nickname in order to appear higher in the members list.\nThis command replaces any member's nickname that includes those special characters with a special character that drags them to the bottom of the list.",
"reminder": "This command requires **{{MANAGE_NICKNAMES, permissions}}**, and only members with lower role hierarchy position can be dehoisted."
"reminder": "This command requires **{{ManageNicknames, permissions}}**, and only members with lower role hierarchy position can be dehoisted."
},
"kickDescription": "Hit somebody with the 👢.",
"kickExtended": {
Expand All @@ -101,7 +101,7 @@
"User1 User2 User3...User10",
"User Reason"
],
"extendedHelp": "This command requires **{{KICK_MEMBERS, permissions}}**, and only members with lower role hierarchy position can be kicked by me. No, the server's owner cannot be kicked.",
"extendedHelp": "This command requires **{{KickMembers, permissions}}**, and only members with lower role hierarchy position can be kicked by me. No, the server's owner cannot be kicked.",
"explainedUsage": [
[
"User/User1/User2",
Expand Down Expand Up @@ -547,13 +547,60 @@
"The amount of time worth of messages to delete, each flag is optional and can be combined. Up to 7 days."
]
],
"extendedHelp": "This command requires **{{BAN_MEMBERS, permissions}}**, and only members with lower role hierarchy position can be banned by me.\nNo, the server's owner cannot be banned.\nThe ban feature from Discord has a feature that allows the moderator to remove all messages from all channels that have been sent in the last 'x' days, being a number between 0 (no days) and 7.\nThe user gets unbanned right after the ban, so it is like a kick, but that can prune many many messages.",
"extendedHelp": "This command requires **{{BanMembers, permissions}}**, and only members with lower role hierarchy position can be banned by me.\nNo, the server's owner cannot be banned.\nThe ban feature from Discord has a feature that allows the moderator to remove all messages from all channels that have been sent in the last 'x' days, being a number between 0 (no days) and 7.\nThe user gets unbanned right after the ban, so it is like a kick, but that can prune many many messages.",
"examples": [
"@Pete",
"@Pete Spamming all channels",
"@Pete 7 All messages sent in 7 are gone now, YEE HAH!"
]
},
"timeoutApplyDescription": "Time out a user.",
"timeoutApplyExtended": {
"usages": [
"Duration User",
"Duration User Reason"
],
"explainedUsage": [
[
"Duration",
"The duration of the timeout. For example 24h for 24 hours."
],
[
"User",
"The user to time out."
],
[
"Reason",
"The reason for the timeout. This will also show in the server's audit logs."
]
],
"examples": [
"30s @Pete",
"2h @Pete Spamming all channels"
]
},
"timeoutUndoDescription": "Remove a time out from a user.",
"timeoutUndoExtended": {
"usages": [
"User",
"User Reason"
],
"explainedUsage": [
[
"User",
"The user to time out."
],
[
"Reason",
"The reason for the timeout. This will also show in the server's audit logs."
]
],
"examples": [
"@Pete",
"@Pete Turns out he was not the one who spammed all channels 🤷"
]
},
"timeoutNotModeratable": "The target cannot be timed out by me.",
"toggleModerationDmDescription": "Toggle moderation DMs.",
"toggleModerationDmExtended": {
"extendedHelp": "This command allows you to toggle moderation DMs. By default, they are on, meaning that any moderation action (automatic or manual) will DM you, but you can disable them with this command."
Expand All @@ -575,7 +622,7 @@
"The reason for the ban removal. This will also show in the server's audit logs."
]
],
"extendedHelp": "This command requires **{{BAN_MEMBERS, permissions}}**. It literally gets somebody from the rubbish bin, cleans them up, and allows the pass to this server's gates.",
"extendedHelp": "This command requires **{{BanMembers, permissions}}**. It literally gets somebody from the rubbish bin, cleans them up, and allows the pass to this server's gates.",
"examples": [
"@Pete",
"@Pete Turns out he was not the one who spammed all channels 🤷"
Expand Down
5 changes: 3 additions & 2 deletions src/languages/en-US/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
"disabledChannels": "A list of channels for disabled commands, for example, setting up a channel called general will forbid all users from using my commands there. Moderators+ override this purposely to allow them to moderate without switching channels.",
"disabledCommands": "The disabled commands, core commands may not be disabled, and moderators will override this. All commands must be in lower case.",
"disableNaturalPrefix": "Whether or not I should listen for my natural prefix, `Skyra,`",
"eventsBanAdd": "This event posts anonymous moderation logs when a user gets banned. You must set up `channels.moderation-logs`.",
"eventsBanRemove": "This event posts anonymous moderation logs when a user gets unbanned. You must set up `channels.moderation-logs`.",
"eventsBanAdd": "This event posts non-bot moderation logs when a user gets banned. You must set up `channels.moderation-logs`.",
"eventsBanRemove": "This event posts non-bot moderation logs when a user gets unbanned. You must set up `channels.moderation-logs`.",
"eventsTimeout": "This event posts non-bot moderation logs when a user's timeout status changes. You must set up `channels.moderation-logs`.",
"eventsUnknownMessages": "Whether or not I should post updates on unknown command messages.",
"eventsTwemojiReactions": "Whether or not twemoji reactions are posted in the reaction logs channel.",
"eventsIncludeBots": "Whether or not I should ignore bots in the server logs.",
Expand Down
4 changes: 4 additions & 0 deletions src/lib/database/entities/GuildEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export class GuildEntity extends BaseEntity implements IBaseEntity {
@Column('boolean', { name: 'events.ban-remove', default: false })
public eventsBanRemove = false;

@ConfigurableKey({ description: LanguageKeys.Settings.EventsTimeout })
@Column('boolean', { name: 'events.timeout', default: false })
public eventsTimeout = false;

@ConfigurableKey({ description: LanguageKeys.Settings.EventsUnknownMessages })
@Column('boolean', { name: 'events.unknown-messages', default: false })
public eventsUnknownMessages = false;
Expand Down
1 change: 1 addition & 0 deletions src/lib/database/keys/settings/Events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const BanAdd = 'eventsBanAdd';
export const BanRemove = 'eventsBanRemove';
export const Timeout = 'eventsTimeout';
export const UnknownMessages = 'eventsUnknownMessages';
export const IncludeTwemoji = 'eventsTwemojiReactions';
export const IncludeBots = 'eventsIncludeBots';
11 changes: 11 additions & 0 deletions src/lib/database/migrations/1708164874479-V79_AddTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { TableColumn, type MigrationInterface, type QueryRunner } from 'typeorm';

export class V79AddTimeout1708164874479 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn('guilds', new TableColumn({ name: 'events.timeout', type: 'boolean', default: false }));
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('guilds', 'events.timeout');
}
}
3 changes: 1 addition & 2 deletions src/lib/discord/Api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { API } from '@discordjs/core/http-only';
import { container } from '@sapphire/framework';

let instance: API;
export function api() {
return (instance ??= new API(container.client.rest as any));
return (container.api ??= new API(container.client.rest as any));
}
1 change: 1 addition & 0 deletions src/lib/i18n/languageKeys/keys/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const DisableNaturalPrefix = T('settings:disableNaturalPrefix');
export const DisabledChannels = T('settings:disabledChannels');
export const EventsBanAdd = T('settings:eventsBanAdd');
export const EventsBanRemove = T('settings:eventsBanRemove');
export const EventsTimeout = T('settings:eventsTimeout');
export const EventsUnknownMessages = T('settings:eventsUnknownMessages');
export const EventsTwemojiReactions = T('settings:eventsTwemojiReactions');
export const MessagesIgnoreChannels = T('settings:messagesIgnoreChannels');
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/languageKeys/keys/commands/Case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const OptionsShow = 'commands/case:optionsShow';

export const TimeNotAllowed = FT<{ type: string }>('commands/case:timeNotAllowed');
export const TimeNotAllowedInCompletedEntries = FT<{ caseId: number }>('commands/case:timeNotAllowedInCompletedEntries');
export const TimeEditNotSupported = FT<{ type: string }>('commands/case:timeEditNotSupported');
export const TimeTooEarly = FT<{ time: string }>('commands/case:timeTooEarly');
export const ListEmpty = T('commands/case:listEmpty');
export const ListDetailsTitle = FT<{ count: number }>('commands/case:listDetailsTitle');
Expand Down
5 changes: 5 additions & 0 deletions src/lib/i18n/languageKeys/keys/commands/Moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const RestrictVoiceDescription = T('commands/moderation:restrictVoiceDesc
export const RestrictVoiceExtended = T<LanguageHelpDisplayOptions>('commands/moderation:restrictVoiceExtended');
export const SoftBanDescription = T('commands/moderation:softBanDescription');
export const SoftBanExtended = T<LanguageHelpDisplayOptions>('commands/moderation:softBanExtended');
export const TimeoutApplyDescription = T('commands/moderation:timeoutApplyDescription');
export const TimeoutApplyExtended = T<LanguageHelpDisplayOptions>('commands/moderation:timeoutApplyExtended');
export const TimeoutUndoDescription = T('commands/moderation:timeoutUndoDescription');
export const TimeoutUndoExtended = T<LanguageHelpDisplayOptions>('commands/moderation:timeoutUndoExtended');
export const TimeoutNotModeratable = T('commands/moderation:timeoutNotModeratable');
export const ToggleModerationDmDescription = T('commands/moderation:toggleModerationDmDescription');
export const ToggleModerationDmExtended = T<LanguageHelpDisplayOptions>('commands/moderation:toggleModerationDmExtended');
export const UnbanDescription = T('commands/moderation:unbanDescription');
Expand Down
33 changes: 24 additions & 9 deletions src/lib/moderation/actions/ModerationActionTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { api } from '#lib/discord/Api';
import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction';
import { resolveOnErrorCodes } from '#utils/common';
import { days, resolveOnErrorCodes } from '#utils/common';
import { getLogger } from '#utils/functions';
import { TypeVariation } from '#utils/moderationConstants';
import { isNullish } from '@sapphire/utilities';
import { RESTJSONErrorCodes, type Guild, type Snowflake } from 'discord.js';

export class ModerationActionTimeout extends ModerationAction<number | null, TypeVariation.Timeout> {
export class ModerationActionTimeout extends ModerationAction<TypeVariation.Timeout> {
public constructor() {
super({
type: TypeVariation.Timeout,
isUndoActionAvailable: true,
maximumDuration: days(28),
durationRequired: true,
durationExternal: true,
logPrefix: 'Moderation => Timeout'
});
}
Expand All @@ -19,23 +23,34 @@ export class ModerationActionTimeout extends ModerationAction<number | null, Typ
return !isNullish(member) && member.isCommunicationDisabled();
}

protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data<number>) {
protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry) {
const reason = await this.getReason(guild, entry.reason);
const time = this.#getCommunicationDisabledUntil(data);
const time = new Date(Date.now() + entry.duration!).toISOString();
await api().guilds.editMember(guild.id, entry.userId, { communication_disabled_until: time }, { reason });

await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId });
}

protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data<number>) {
protected override handleApplyPreOnStart(guild: Guild, entry: ModerationAction.Entry) {
getLogger(guild).timeout.set(entry.userId, { userId: entry.moderatorId, reason: entry.reason });
}

protected override handleApplyPreOnError(_error: Error, guild: Guild, entry: ModerationAction.Entry) {
getLogger(guild).timeout.unset(entry.userId);
}

protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry) {
const reason = await this.getReason(guild, entry.reason, true);
const time = this.#getCommunicationDisabledUntil(data);
await api().guilds.editMember(guild.id, entry.userId, { communication_disabled_until: time }, { reason });
await api().guilds.editMember(guild.id, entry.userId, { communication_disabled_until: null }, { reason });

await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId });
}

#getCommunicationDisabledUntil(data: ModerationAction.Data<number>) {
return isNullish(data.context) ? null : new Date(data.context).toISOString();
protected override handleUndoPreOnStart(guild: Guild, entry: ModerationAction.Entry) {
getLogger(guild).timeout.set(entry.userId, { userId: entry.moderatorId, reason: entry.reason });
}

protected override handleUndoPreOnError(_error: Error, guild: Guild, entry: ModerationAction.Entry) {
getLogger(guild).timeout.unset(entry.userId);
}
}
Loading

0 comments on commit 030470d

Please sign in to comment.