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
13 changes: 13 additions & 0 deletions typescript/src/lib/moderation/actions/ActionAddRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Schedules } from '#lib/types/Enums';
import { Moderation } from '#utils/constants';
import { BaseRoleAction, RoleAction } from './base';

export class ActionAddRole extends BaseRoleAction {
public constructor() {
super({
task: Schedules.ModerationEndAddRole,
roleAction: RoleAction.Add,
type: Moderation.TypeCodes.AddRole
});
}
}
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
151 changes: 151 additions & 0 deletions typescript/src/lib/moderation/actions/base/BaseAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { ModerationEntity } from '#lib/database';
import { LanguageKeys } from '#lib/i18n/languageKeys';
import type { ModerationManagerCreateData } from '#lib/moderation';
import { Moderation } from '#utils/constants';
import { resolveOnErrorCodes } from '#utils/util';
import { err, ok, Result } from '@sapphire/framework';
import type { Awaited } from '@sapphire/pieces';
import { RESTJSONErrorCodes } from 'discord-api-types/v6';
import { Guild, GuildMember, MessageEmbed, User } from 'discord.js';

export type AttachContext<Options, Context> = Options & { context: Context };
export type AttachPreHandledContext<Options, Context> = AttachContext<Options, Context> & { entry: ModerationEntity };
export type AttachResultContext<Options, Context> = AttachContext<Options, Context> & { results: HandleMembersResults };

export type HandleMembersResult = Result<User, string>;
export type HandleMembersResults = HandleMembersResult[];
export type HandleMembersResultAsync = Promise<HandleMembersResults>;

export type HandleMemberResult = Awaited<HandleMembersResult>;
export type HandleMemberResultAsync = Promise<HandleMembersResult>;

export abstract class BaseAction<Options extends BaseAction.RunOptions = BaseAction.RunOptions, Context = unknown> {
protected readonly type: number;

public constructor(options: BaseAction.Options) {
this.type = options.type;
}

public async run(options: Options) {
const context = await this.preHandle(options);
const contextOptions = { ...options, context };

const results = await this.handleTargets(contextOptions);
const resultOptions = { ...contextOptions, results };

await this.postHandle(resultOptions);
return results;
}

protected ok(value: User): HandleMembersResult {
return ok(value);
}

protected error(error: string): HandleMembersResult {
return err(error);
}

protected preHandle(options: Options): Awaited<Context>;
protected preHandle(): unknown {
return null;
}

protected async handleTargets(options: AttachContext<Options, Context>): HandleMembersResultAsync {
const results: HandleMembersResults = [];
for (const user of options.users) {
// Pre-handle target:
const entry = await this.preHandleTarget(user, options);
const entryOptions = { ...options, entry };

// Handle target, perform the action:
const result = await this.handleTarget(user, entryOptions);
results.push(result);

// Post-handle target:
await this.postHandleTarget(user, entryOptions);
}

return results;
}

protected async preHandleTarget(user: User, options: AttachContext<Options, Context>): Promise<ModerationEntity> {
const entry = options.guild.moderation.create(this.transformModerationEntryOptions(user, options));
if (options.sendDirectMessage) await this.postHandleTargetLogSendDirectMessage(entry, options);
return (await entry.create())!;
}

protected abstract handleTarget(user: User, options: AttachPreHandledContext<Options, Context>): HandleMemberResultAsync;

protected async postHandleTarget(user: User, options: AttachPreHandledContext<Options, Context>): Promise<void> {
await this.postHandleTargetLog(user, options);
}

protected async postHandleTargetLog(user: User, options: AttachPreHandledContext<Options, Context>): Promise<unknown>;
protected async postHandleTargetLog(_: User, options: AttachPreHandledContext<Options, Context>): Promise<void> {
await options.entry.create();
}

protected async postHandleTargetLogSendDirectMessage(entry: ModerationEntity, options: AttachContext<Options, Context>): Promise<void> {
try {
const target = await entry.fetchUser();
const embed = await this.buildEmbed(entry, options);
await resolveOnErrorCodes(target.send(embed), RESTJSONErrorCodes.CannotSendMessagesToThisUser);
} catch (error) {
options.guild.client.logger.error(error);
}
}

protected postHandle(options: AttachResultContext<Options, Context>): Awaited<unknown>;
protected postHandle(): unknown {
return null;
}

protected transformModerationEntryOptions(user: User, options: Options): ModerationManagerCreateData {
return {
type: this.type,
userID: user.id,
moderatorID: options.author.id,
duration: null,
imageURL: options.image,
reason: options.reason
};
}

private async buildEmbed(entry: ModerationEntity, options: AttachContext<Options, Context>) {
const descriptionKey = entry.reason
? entry.duration
? LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithReasonWithDuration
: LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithReason
: entry.duration
? LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithDuration
: LanguageKeys.Commands.Moderation.ModerationDmDescription;

const t = await options.guild.fetchT();
const embed = new MessageEmbed() //
.setDescription(t(descriptionKey, { guild: options.guild.name, title: entry.title, reason: entry.reason, duration: entry.duration }))
.setFooter(t(LanguageKeys.Commands.Moderation.ModerationDmFooter));

if (options.displayModerator) {
embed.setAuthor(options.author.user.username, options.author.user.displayAvatarURL({ size: 128, format: 'png', dynamic: true }));
}

return embed;
}
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BaseAction {
export interface Options {
type: Moderation.TypeCodes;
}

export interface RunOptions {
author: GuildMember;
sendDirectMessage: boolean;
guild: Guild;
image: string | null;
displayModerator: boolean;
reason: string;
users: readonly User[];
}
}
58 changes: 58 additions & 0 deletions typescript/src/lib/moderation/actions/base/BasePreSetRoleAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { isNullish } from '@sapphire/utilities';
import { PermissionOverwriteOption, RoleData } from 'discord.js';
import { AttachPreHandledContext } from './BaseAction';
import { BaseRoleAction } from './BaseRoleAction';

export abstract class BasePreSetRoleAction extends BaseRoleAction {
protected readonly roleKey: BaseRoleAction.RoleKey;
protected readonly roleData: RoleData;
protected readonly rolePermissions: RolePermissionOverwriteOption;

public constructor(options: BasePreSetRoleAction.Options) {
super(options);
this.roleKey = options.roleKey;
this.roleData = options.roleData;
this.rolePermissions = options.rolePermissions;
}

protected async preHandle(options: BasePreSetRoleAction.RunOptions): Promise<BaseRoleAction.Context> {
const roleID = await options.guild.readSettings(this.roleKey);
if (isNullish(roleID)) this.throwMissingRole();

const roles = options.guild.roles.cache;
const role = roles.get(roleID);
if (role === undefined) {
await options.guild.writeSettings([[this.roleKey, null]]);
this.throwMissingRole();
}

const excluded = await this.preHandleExcluded(options);
return { role, excluded };
}
}

export interface RolePermissionOverwriteOptionField {
options: PermissionOverwriteOption;
permissions: Permissions;
}

export interface RolePermissionOverwriteOption {
category: RolePermissionOverwriteOptionField;
text: RolePermissionOverwriteOptionField | null;
voice: RolePermissionOverwriteOptionField | null;
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BasePreSetRoleAction {
export type RoleKey = BaseRoleAction.RoleKey;

export interface Options extends BaseRoleAction.Options {
roleKey: RoleKey;
roleData: RoleData;
rolePermissions: RolePermissionOverwriteOption;
}

export type Context = BaseRoleAction.Context;
export type RunOptions = Omit<BaseRoleAction.RunOptions, 'role'>;
export type PreHandledRunOptions = AttachPreHandledContext<RunOptions, Context>;
}
60 changes: 60 additions & 0 deletions typescript/src/lib/moderation/actions/base/BaseReversibleAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ModerationEntity } from '#lib/database';
import type { ModerationManagerCreateData } from '#lib/moderation';
import type { Schedules } from '#lib/types/Enums';
import type { User } from 'discord.js';
import { AttachPreHandledContext, BaseAction } from './BaseAction';

export abstract class BaseReversibleAction<
Options extends BaseReversibleAction.RunOptions = BaseReversibleAction.RunOptions,
Context = unknown
> extends BaseAction<Options, Context> {
protected readonly task: Schedules;

public constructor(options: BaseReversibleAction.Options) {
super(options);
this.task = options.task;
}

protected async postHandleTarget(user: User, options: AttachPreHandledContext<Options, Context>): Promise<void> {
await this.postHandleTargetTask(user, options);
await super.postHandleTarget(user, options);
}

protected async postHandleTargetTask(user: User, options: AttachPreHandledContext<Options, Context>): Promise<void> {
// Retrieve all moderation logs regarding a user.
const logs = await options.guild.moderation.fetch(user.id);

// Filter all logs by valid and by type of mute (isType will include temporary and invisible).
const extra = this.postHandleTargetTaskExtraCallback(user, options);
const log = logs.filter((entry) => !entry.invalidated && entry.createdAt !== null && entry.isType(this.type) && extra(entry)).last();
if (log === undefined) return;

// Cancel the previous moderation log's task:
const { task } = log;
if (task && !task.running) await task.delete();
}

protected postHandleTargetTaskExtraCallback(user: User, options: AttachPreHandledContext<Options, Context>): BaseReversibleAction.ExtraCallback;
protected postHandleTargetTaskExtraCallback(): BaseReversibleAction.ExtraCallback {
return () => true;
}

protected transformModerationEntryOptions(user: User, options: Options): ModerationManagerCreateData {
return { ...super.transformModerationEntryOptions(user, options), duration: options.duration };
}
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BaseReversibleAction {
export interface Options extends BaseAction.Options {
task: Schedules;
}

export interface RunOptions extends BaseAction.RunOptions {
duration: number | null;
}

export interface ExtraCallback {
(entry: ModerationEntity): boolean;
}
}
Loading