diff --git a/packages/core/src/notifications/INotifier.ts b/packages/core/src/notifications/INotifier.ts index 872f5f92..bb7247ce 100644 --- a/packages/core/src/notifications/INotifier.ts +++ b/packages/core/src/notifications/INotifier.ts @@ -24,6 +24,11 @@ export interface LogModCaseOptions { target: APIUser | null; } +export interface HistoryEmbedOptions { + cases: CaseWithLogMessage[]; + target: APIUser; +} + @injectable() export abstract class INotifier { public readonly ACTION_COLORS_MAP = { @@ -55,4 +60,5 @@ export abstract class INotifier { public abstract generateModCaseEmbed(options: LogModCaseOptions): APIEmbed; public abstract logModCase(options: LogModCaseOptions): Promise; public abstract tryNotifyTargetModCase(modCase: Selectable): Promise; + public abstract generateHistoryEmbed(options: HistoryEmbedOptions): APIEmbed; } diff --git a/packages/core/src/notifications/Notifier.ts b/packages/core/src/notifications/Notifier.ts index 7ecc6e0a..724d9e6e 100644 --- a/packages/core/src/notifications/Notifier.ts +++ b/packages/core/src/notifications/Notifier.ts @@ -1,15 +1,15 @@ import { addFields, truncateEmbed } from '@chatsift/discord-utils'; import { API, type APIEmbed, type APIMessage } from '@discordjs/core'; -import { messageLink } from '@discordjs/formatters'; +import { messageLink, time, TimestampStyles } from '@discordjs/formatters'; import { inject, injectable } from 'inversify'; import type { Selectable } from 'kysely'; import type { Logger } from 'pino'; import { INJECTION_TOKENS } from '../container.js'; import { IDatabase } from '../database/IDatabase.js'; -import { LogWebhookKind, type ModCase } from '../db.js'; +import { LogWebhookKind, ModCaseKind, type ModCase } from '../db.js'; import { computeAvatarUrl } from '../util/computeAvatar.js'; import { userToEmbedAuthor } from '../util/userToEmbedData.js'; -import { INotifier, type DMUserOptions, type LogModCaseOptions } from './INotifier.js'; +import { INotifier, type DMUserOptions, type HistoryEmbedOptions, type LogModCaseOptions } from './INotifier.js'; @injectable() export class Notifier extends INotifier { @@ -138,4 +138,68 @@ export class Notifier extends INotifier { return false; } } + + public override generateHistoryEmbed(options: HistoryEmbedOptions): APIEmbed { + let points = 0; + const counts = { + ban: 0, + kick: 0, + timeout: 0, + warn: 0, + }; + + const colors = [0x80f31f, 0xc7c101, 0xf47b7b, 0xf04848] as const; + const details: string[] = []; + + for (const cs of options.cases) { + if (cs.kind === ModCaseKind.Ban) { + counts.ban++; + points += 3; + } else if (cs.kind === ModCaseKind.Kick) { + counts.kick++; + points += 2; + } else if (cs.kind === ModCaseKind.Timeout) { + counts.timeout++; + points += 0.5; + } else if (cs.kind === ModCaseKind.Warn) { + counts.warn++; + points += 0.25; + } else { + continue; + } + + const action = cs.kind.toUpperCase(); + const caseId = cs.logMessage + ? `[#${cs.id}](${messageLink(cs.logMessage.channelId, cs.logMessage.messageId, cs.guildId)})` + : `#${cs.id}`; + const reason = cs.reason ? ` - ${cs.reason}` : ''; + + details.push(`• ${time(cs.createdAt, TimestampStyles.LongDate)} \`${action}\` ${caseId}${reason}`); + } + + const color = colors[points > 0 && points < 1 ? 1 : Math.min(Math.floor(points), 3)]; + + const embed: APIEmbed = { + author: userToEmbedAuthor(options.target, options.target.id), + color, + }; + + if (points === 0) { + embed.description = 'No moderation history'; + return embed; + } + + const footer = Object.entries(counts).reduce((arr, [type, count]) => { + if (count > 0) { + arr.push(`${count} ${type}${count === 1 ? '' : 's'}`); + } + + return arr; + }, []); + + embed.footer = { text: footer.join(' | ') }; + embed.description = details.join('\n'); + + return embed; + } } diff --git a/services/interactions/src/handlers/history.ts b/services/interactions/src/handlers/history.ts index 57d30271..20b8a024 100644 --- a/services/interactions/src/handlers/history.ts +++ b/services/interactions/src/handlers/history.ts @@ -105,22 +105,16 @@ export default class HistoryHandler implements HandlerModule(cases.map((cs) => cs.modId)); - const mods = await Promise.all([...modIds].map(async (id) => this.api.users.get(id))); - const modsMap = new Map(mods.map((mod) => [mod.id, mod])); + const historyEmbed = this.notifier.generateHistoryEmbed({ + cases, + target, + }); yield* HandlerStep.from({ action: ActionKind.Reply, options: { content: `Mod history for ${target.username}; page ${page + 1}`, - embeds: cases.map((cs) => - this.notifier.generateModCaseEmbed({ - mod: modsMap.get(cs.modId) ?? null, - modCase: cs, - references: [], - target, - }), - ), + embeds: [historyEmbed], }, }); } diff --git a/services/interactions/src/handlers/mod.ts b/services/interactions/src/handlers/mod.ts index d6313827..2f7b2419 100644 --- a/services/interactions/src/handlers/mod.ts +++ b/services/interactions/src/handlers/mod.ts @@ -568,9 +568,10 @@ export default class ModHandler implements HandlerModule - this.notifier.generateModCaseEmbed({ modCase, mod: interaction.member!.user, target, references: [] }), - ); + const historyEmbed = this.notifier.generateHistoryEmbed({ + cases: previousCases, + target, + }); const stateId = nanoid(); await this.stateStore.setPendingModCase(stateId, { @@ -590,7 +591,7 @@ export default class ModHandler implements HandlerModule