Skip to content

Commit

Permalink
feat: add timeout support
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet committed Mar 14, 2024
1 parent 9ab93ee commit d6a5469
Show file tree
Hide file tree
Showing 16 changed files with 166 additions and 15 deletions.
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;
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
2 changes: 2 additions & 0 deletions src/lib/database/entities/ModerationEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export class ModerationEntity extends BaseEntity {
return TypeVariationAppealNames.AddRole;
case TypeVariation.RemoveRole:
return TypeVariationAppealNames.RemoveRole;
case TypeVariation.Timeout:
return TypeVariationAppealNames.Timeout;
default:
return null;
}
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');
}
}
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
8 changes: 7 additions & 1 deletion src/lib/moderation/managers/LoggerManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { writeSettings, type GuildSettingsOfType } from '#lib/database';
import { LoggerTypeManager } from '#lib/moderation/managers/LoggerTypeManager';
import { LoggerTypeManager, type LoggerTypeContext } from '#lib/moderation/managers/LoggerTypeManager';
import { toErrorCodeResult } from '#utils/common';
import { getCodeStyle, getLogPrefix } from '#utils/functions/pieces';
import { EmbedBuilder } from '@discordjs/builders';
Expand All @@ -8,6 +8,7 @@ import { isFunction, isNullish, isNullishOrEmpty, type Awaitable, type Nullish }
import { PermissionFlagsBits, RESTJSONErrorCodes, type Guild, type GuildBasedChannel, type MessageCreateOptions, type Snowflake } from 'discord.js';

export class LoggerManager {
public readonly timeout = new LoggerTypeManager<TimeoutLoggerTypeContext>(this);
public readonly prune = new LoggerTypeManager(this);

#guild: Guild;
Expand Down Expand Up @@ -121,3 +122,8 @@ export interface LoggerManagerSendOptions {
export type LoggerManagerSendMessageOptions = MessageCreateOptions | EmbedBuilder | EmbedBuilder[];

const LogPrefix = getLogPrefix('LoggerManager');

export interface TimeoutLoggerTypeContext extends LoggerTypeContext {
oldValue: number | null;
newValue: number | null;
}
10 changes: 10 additions & 0 deletions src/lib/moderation/managers/LoggerTypeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export class LoggerTypeManager<Entry extends LoggerTypeContext = LoggerTypeConte
this.#manager = manager;
}

/**
* Returns whether or not the context data is set, which will always be true
* when an action was taken by Skyra.
* @param id The ID of the context data to check.
*/
public isSet(id: Snowflake) {
return this.#context.has(id);
}

/**
* Wait for the context data to be set.
* @param id The ID of the context data to wait for.
Expand Down Expand Up @@ -104,4 +113,5 @@ export class LoggerTypeManager<Entry extends LoggerTypeContext = LoggerTypeConte

export interface LoggerTypeContext {
userId: Snowflake;
reason?: string;
}
18 changes: 18 additions & 0 deletions src/lib/util/Security/ModerationActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export interface ModerationAction {
restrictedVoice: string;
setNickname: string;
removeRole: string;
timeout: string;
}

export class ModerationActions {
Expand Down Expand Up @@ -379,6 +380,23 @@ export class ModerationActions {
return (await moderationLog.create())!;
}

public async timeout(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) {
await api().guilds.editMember(
this.guild.id,
rawOptions.userId,
{ communication_disabled_until: isNullishOrZero(rawOptions.duration) ? null : new Date(Date.now() + rawOptions.duration).toISOString() },
{ reason: rawOptions.reason ?? undefined }
);
const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Timeout, TypeMetadata.Temporary);
const moderationLog = getModeration(this.guild).create(options);
await this.sendDM(moderationLog, sendOptions);
return (await moderationLog.create())!;
}

public async timeoutEnd(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) {

Check failure on line 396 in src/lib/util/Security/ModerationActions.ts

View workflow job for this annotation

GitHub Actions / Building NodeJS

'rawOptions' is declared but its value is never read.

Check failure on line 396 in src/lib/util/Security/ModerationActions.ts

View workflow job for this annotation

GitHub Actions / Building NodeJS

'sendOptions' is declared but its value is never read.

Check failure on line 396 in src/lib/util/Security/ModerationActions.ts

View workflow job for this annotation

GitHub Actions / Linting NodeJS

Delete `⏎↹↹⏎↹`

}

public async kick(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) {
const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Kick);
const moderationLog = getModeration(this.guild).create(options);
Expand Down
15 changes: 12 additions & 3 deletions src/lib/util/moderationConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ export const enum TypeVariation {
SetNickname,
AddRole,
RemoveRole,
RestrictedEmoji
RestrictedEmoji,
Timeout
}

export const enum TypeMetadata {
None = 0,
Appeal = 1 << 0,
Temporary = 1 << 1,
Fast = 1 << 2,
Invalidated = 1 << 3
Invalidated = 1 << 3,
Completed = 1 << 4
}

const TypeCodes = {
Expand Down Expand Up @@ -53,6 +55,7 @@ const TypeCodes = {
UnSetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Appeal),
UnAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Appeal),
UnRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Appeal),
UnTimeout: combineTypeData(TypeVariation.Timeout, TypeMetadata.Appeal),
TemporaryWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Temporary),
TemporaryMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Temporary),
TemporaryBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Temporary),
Expand All @@ -65,6 +68,7 @@ const TypeCodes = {
TemporarySetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Temporary),
TemporaryAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Temporary),
TemporaryRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Temporary),
TemporaryTimeout: combineTypeData(TypeVariation.Timeout, TypeMetadata.Temporary),
FastTemporaryWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Temporary | TypeMetadata.Fast),
FastTemporaryMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Temporary | TypeMetadata.Fast),
FastTemporaryBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Temporary | TypeMetadata.Fast),
Expand All @@ -77,6 +81,7 @@ const TypeCodes = {
FastTemporarySetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Temporary | TypeMetadata.Fast),
FastTemporaryAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Temporary | TypeMetadata.Fast),
FastTemporaryRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Temporary | TypeMetadata.Fast),
FastTemporaryTimeout: combineTypeData(TypeVariation.Timeout, TypeMetadata.Temporary | TypeMetadata.Fast),
Prune: combineTypeData(TypeVariation.Prune),
SetNickname: combineTypeData(TypeVariation.SetNickname),
AddRole: combineTypeData(TypeVariation.AddRole),
Expand Down Expand Up @@ -123,6 +128,7 @@ const Metadata = new Map<TypeCodes, ModerationTypeAssets>([
[TypeCodes.UnSetNickname, { color: Colors.LightBlue, title: 'Reverted Set Nickname' }],
[TypeCodes.UnAddRole, { color: Colors.LightBlue, title: 'Reverted Add Role' }],
[TypeCodes.UnRemoveRole, { color: Colors.LightBlue, title: 'Reverted Remove Role' }],
[TypeCodes.UnTimeout, { color: Colors.LightBlue, title: 'Reverted Timeout' }],
[TypeCodes.TemporaryWarning, { color: Colors.Yellow300, title: 'Temporary Warning' }],
[TypeCodes.TemporaryMute, { color: Colors.Amber300, title: 'Temporary Mute' }],
[TypeCodes.TemporaryBan, { color: Colors.Red300, title: 'Temporary Ban' }],
Expand All @@ -135,6 +141,7 @@ const Metadata = new Map<TypeCodes, ModerationTypeAssets>([
[TypeCodes.TemporarySetNickname, { color: Colors.Lime300, title: 'Temporary Set Nickname' }],
[TypeCodes.TemporaryAddRole, { color: Colors.Lime300, title: 'Temporarily Added Role' }],
[TypeCodes.TemporaryRemoveRole, { color: Colors.Lime300, title: 'Temporarily Removed Role' }],
[TypeCodes.TemporaryTimeout, { color: Colors.Amber300, title: 'Temporarily Timed Out' }],
[TypeCodes.FastTemporaryWarning, { color: Colors.Yellow300, title: 'Temporary Warning' }],
[TypeCodes.FastTemporaryMute, { color: Colors.Amber300, title: 'Temporary Mute' }],
[TypeCodes.FastTemporaryBan, { color: Colors.Red300, title: 'Temporary Ban' }],
Expand All @@ -147,6 +154,7 @@ const Metadata = new Map<TypeCodes, ModerationTypeAssets>([
[TypeCodes.FastTemporarySetNickname, { color: Colors.Lime300, title: 'Temporary Set Nickname' }],
[TypeCodes.FastTemporaryAddRole, { color: Colors.Lime300, title: 'Temporarily Added Role' }],
[TypeCodes.FastTemporaryRemoveRole, { color: Colors.Lime300, title: 'Temporarily Removed Role' }],
[TypeCodes.FastTemporaryTimeout, { color: Colors.Amber300, title: 'Temporarily Timed Out' }],
[TypeCodes.Prune, { color: Colors.Brown, title: 'Prune' }],
[TypeCodes.SetNickname, { color: Colors.Lime, title: 'Set Nickname' }],
[TypeCodes.AddRole, { color: Colors.Lime, title: 'Added Role' }],
Expand All @@ -165,7 +173,8 @@ export const enum TypeVariationAppealNames {
RestrictedVoice = 'moderationEndRestrictionVoice',
SetNickname = 'moderationEndSetNickname',
AddRole = 'moderationEndAddRole',
RemoveRole = 'moderationEndRemoveRole'
RemoveRole = 'moderationEndRemoveRole',
Timeout = 'moderationEndTimeout'
}

export const enum SchemaKeys {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ export class UserListener extends Listener {
previousName === null
? LanguageKeys.Events.Guilds.Members.NameUpdatePreviousWasNotSet
: LanguageKeys.Events.Guilds.Members.NameUpdatePreviousWasSet,
{
previousName
}
{ previousName }
),
t(
nextName === null
Expand Down
44 changes: 44 additions & 0 deletions src/listeners/guilds/members/guildMemberUpdateTimeoutNotify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { GuildSettings, readSettings } from '#lib/database';
import { Events } from '#lib/types';
import { getLogger, getModeration } from '#utils/functions';
import { TypeMetadata, TypeVariation } from '#utils/moderationConstants';
import { ApplyOptions } from '@sapphire/decorators';
import { Listener } from '@sapphire/framework';
import { isNumber } from '@sapphire/utilities';
import type { GuildMember } from 'discord.js';

@ApplyOptions<Listener.Options>({ event: Events.GuildMemberUpdate })
export class UserListener extends Listener {
public async run(previous: GuildMember, next: GuildMember) {
const prevTimeout = this.#getTimeout(previous);
const nextTimeout = this.#getTimeout(next);
if (prevTimeout === nextTimeout) return;

const { user, guild } = next;
const logger = getLogger(guild);
const actionBySkyra = logger.timeout.isSet(guild.id);
const contextPromise = logger.timeout.wait(guild.id);

// If the action was done by Skyra, or external timeout is enabled, create a moderation action:
if (actionBySkyra || (await readSettings(next, GuildSettings.Events.Timeout))) {
const context = await contextPromise;
const moderation = getModeration(guild);
await moderation.waitLock();
await moderation
.create({
userId: user.id,
moderatorId: context?.userId ?? this.container.client.id!,
type: TypeVariation.Timeout,
metadata: nextTimeout ? TypeMetadata.Temporary : TypeMetadata.Appeal,
duration: nextTimeout,
reason: context?.reason
})
.create();
}
}

#getTimeout(member: GuildMember) {
const timeout = member.communicationDisabledUntilTimestamp;
return isNumber(timeout) && timeout >= Date.now() ? timeout : null;
}
}
11 changes: 7 additions & 4 deletions src/listeners/guilds/members/rawGuildMemberUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,35 @@ import { api } from '#lib/discord/Api';
import { floatPromise } from '#utils/common';
import { ApplyOptions } from '@sapphire/decorators';
import { Listener } from '@sapphire/framework';
import { isNullish } from '@sapphire/utilities';
import {
AuditLogEvent,
GatewayDispatchEvents,
PermissionFlagsBits,
type GatewayGuildMemberUpdateDispatch,
type GatewayGuildMemberUpdateDispatchData,
type Guild,
type RESTGetAPIAuditLogResult
} from 'discord.js';

type GatewayData = Readonly<GatewayGuildMemberUpdateDispatchData>;

@ApplyOptions<Listener.Options>({ event: GatewayDispatchEvents.GuildMemberUpdate, emitter: 'ws' })
export class UserListener extends Listener {
private readonly requiredPermissions = PermissionFlagsBits.ViewAuditLog;

public run(data: GatewayGuildMemberUpdateDispatch['d']) {
public run(data: GatewayData) {
const guild = this.container.client.guilds.cache.get(data.guild_id);

// If the guild does not exist for some reason, skip:
if (typeof guild === 'undefined') return;
if (isNullish(guild)) return;

// If the bot doesn't have the required permissions, skip:
if (!guild.members.me?.permissions.has(this.requiredPermissions)) return;

floatPromise(this.handleRoleSets(guild, data));
}

private async handleRoleSets(guild: Guild, data: Readonly<GatewayGuildMemberUpdateDispatch['d']>) {
private async handleRoleSets(guild: Guild, data: GatewayData) {
// Handle unique role sets
let hasMultipleRolesInOneSet = false;
const allRoleSets = await readSettings(guild, GuildSettings.Roles.UniqueRoleSets);
Expand Down
23 changes: 22 additions & 1 deletion src/listeners/guilds/rawGuildAuditLogEntryCreateLoggerTrack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getLogger } from '#utils/functions/guild';
import { ApplyOptions } from '@sapphire/decorators';
import { Listener } from '@sapphire/framework';
import { AuditLogEvent, GatewayDispatchEvents, type GatewayGuildAuditLogEntryCreateDispatchData } from 'discord.js';
import { isNullish, isNullishOrEmpty } from '@sapphire/utilities';
import { AuditLogEvent, GatewayDispatchEvents, Guild, type GatewayGuildAuditLogEntryCreateDispatchData } from 'discord.js';

@ApplyOptions<Listener.Options>({ event: GatewayDispatchEvents.GuildAuditLogEntryCreate, emitter: 'ws' })
export class UserListener extends Listener {
Expand All @@ -10,11 +11,31 @@ export class UserListener extends Listener {
if (!guild) return;

switch (data.action_type) {
case AuditLogEvent.MemberUpdate:
return this.#handleMemberUpdateTimeout(guild, data);
case AuditLogEvent.MessageBulkDelete:
getLogger(guild).prune.setFromAuditLogs(data.target_id!, { userId: data.user_id! });
break;
default:
break;
}
}

#handleMemberUpdateTimeout(guild: Guild, data: GatewayGuildAuditLogEntryCreateDispatchData) {
let oldValue: number | null = null;
let newValue: number | null = null;
if (!isNullishOrEmpty(data.changes)) {
const change = data.changes.find((change) => change.key === 'communication_disabled_until');
if (isNullish(change)) return;
if (!isNullish(change.old_value)) oldValue = Date.parse(change.old_value as string);
if (!isNullish(change.new_value)) newValue = Date.parse(change.new_value as string);
}

getLogger(guild).timeout.setFromAuditLogs(data.target_id!, {
userId: data.user_id!,
reason: data.reason,
oldValue,
newValue
});
}
}
21 changes: 21 additions & 0 deletions src/tasks/moderation/moderationEndTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { LanguageKeys } from '#lib/i18n/languageKeys';
import { ModerationTask, type ModerationData } from '#lib/moderation';
import { getSecurity } from '#utils/functions';
import { fetchT } from '@sapphire/plugin-i18next';
import type { Guild } from 'discord.js';

export class UserModerationTask extends ModerationTask {
protected async handle(guild: Guild, data: ModerationData) {
const t = await fetchT(guild);
await getSecurity(guild).actions.timeout(
{
moderatorId: this.container.client.id!,
userId: data.userID,
reason: `[MODERATION] Timeout released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`
},
data.caseID,
await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID))

Check failure on line 17 in src/tasks/moderation/moderationEndTimeout.ts

View workflow job for this annotation

GitHub Actions / Building NodeJS

Expected 1-2 arguments, but got 3.
);
return null;
}
}

0 comments on commit d6a5469

Please sign in to comment.