Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: user reports #117

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@naval-base/ms": "^3.1.0",
"@sapphire/bitfield": "^1.2.2",
"@sapphire/discord-utilities": "^3.3.0",
"@sapphire/snowflake": "^3.5.3",
"bin-rw": "^0.1.0",
"coral-command": "^0.10.0",
"inversify": "^6.0.2",
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/database/IDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
ModCase,
ModCaseKind,
ModCaseLogMessage,
Report,
Settings,
} from '../db.js';

export type ExperimentWithOverrides = Selectable<Experiment> & { overrides: Selectable<ExperimentOverride>[] };
Expand All @@ -35,6 +37,17 @@ export type CaseWithLogMessage = Selectable<ModCase> & { logMessage: Selectable<

export type UpdateModCaseOptions = Partial<Omit<Selectable<ModCase>, 'id'>> & { references?: number[] };

export interface CreateReporterOptions {
reason: string;
reportId: number;
userId: string;
}

export interface CreateReportOptions {
reportMessageId: string;
reporter: Omit<CreateReporterOptions, 'reportId'>;
}

/**
* Abstraction over all database interactions
*/
Expand Down Expand Up @@ -63,4 +76,8 @@ export abstract class IDatabase {
): Promise<Selectable<ModCaseLogMessage>>;

public abstract getLogWebhook(guildId: string, kind: LogWebhookKind): Promise<Selectable<LogWebhook> | undefined>;

public abstract getSettings(guildId: string): Promise<Selectable<Settings>>;

public abstract createReport(options: CreateReportOptions): Promise<Selectable<Report>>;
}
37 changes: 36 additions & 1 deletion packages/core/src/database/KyselyPostgresDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { sql, Kysely, type Selectable, PostgresDialect, type ExpressionBuilder,
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import type { Logger } from 'pino';
import { INJECTION_TOKENS } from '../container.js';
import type { DB, Incident, LogWebhook, LogWebhookKind, ModCase, ModCaseLogMessage } from '../db.js';
import type { DB, Incident, LogWebhook, LogWebhookKind, ModCase, ModCaseLogMessage, Report, Settings } from '../db.js';
import { Env } from '../util/Env.js';
import {
IDatabase,
type CaseWithLogMessage,
type CreateModCaseOptions,
type CreateReportOptions,
type ExperimentWithOverrides,
type GetModCasesAgainstOptions,
type GetRecentModCasesAgainstOptions,
Expand Down Expand Up @@ -231,6 +232,40 @@ export class KyselyPostgresDatabase extends IDatabase {
.executeTakeFirst();
}

public override async getSettings(guildId: string): Promise<Selectable<Settings>> {
const settings = await this.#database
.selectFrom('Settings')
.selectAll()
.where('guildId', '=', guildId)
.executeTakeFirst();

if (settings) {
return settings;
}

return this.#database.insertInto('Settings').values({ guildId }).returningAll().executeTakeFirstOrThrow();
}

public override async createReport(options: CreateReportOptions): Promise<Selectable<Report>> {
return this.#database.transaction().execute(async (trx) => {
const report = await trx
.insertInto('Report')
.values({
reportMessageId: options.reportMessageId,
})
.returningAll()
.executeTakeFirstOrThrow();

await trx
.insertInto('Reporter')
.values({ ...options.reporter, reportId: report.id })
.returningAll()
.executeTakeFirstOrThrow();

return report;
});
}

private readonly withLogMessage = (query: ExpressionBuilder<DB, 'ModCase'>) => [
jsonObjectFrom(
query
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ export type ModCaseLogMessage = {
messageId: string;
channelId: string;
};
export type Report = {
id: Generated<number>;
reportMessageId: string;
acknowledged: Generated<boolean>;
};
export type Reporter = {
reportId: number;
userId: string;
reason: string;
};
export type Settings = {
guildId: string;
reportChannelId: string | null;
};
export type DB = {
CaseReference: CaseReference;
Experiment: Experiment;
Expand All @@ -71,4 +85,7 @@ export type DB = {
LogWebhook: LogWebhook;
ModCase: ModCase;
ModCaseLogMessage: ModCaseLogMessage;
Report: Report;
Reporter: Reporter;
Settings: Settings;
};
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ export * from './experiments/IExperimentHandler.js';
export * from './notifications/INotifier.js';

export * from './util/computeAvatar.js';
export * from './util/computeModalFields.js';
export * from './util/DependencyManager.js';
export * from './util/encode.js';
export * from './util/Env.js';
export * from './util/PermissionsBitField.js';
export * from './util/promiseAllObject.js';
export * from './util/setupCrashLogs.js';
export * from './util/userMessageToEmbed.js';
export * from './util/userToEmbedData.js';

export * from './container.js';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/notifications/INotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@ export abstract class INotifier {
public abstract logModCase(options: LogModCaseOptions): Promise<void>;
public abstract tryNotifyTargetModCase(modCase: Selectable<ModCase>): Promise<boolean>;
public abstract generateHistoryEmbed(options: HistoryEmbedOptions): APIEmbed;
public abstract logReport(guildId: Snowflake, message: APIMessage): Promise<void>;
}
14 changes: 13 additions & 1 deletion packages/core/src/notifications/Notifier.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { addFields, truncateEmbed } from '@chatsift/discord-utils';
import { API, type APIEmbed, type APIMessage } from '@discordjs/core';
import { API, type APIEmbed, type APIMessage, type Snowflake } from '@discordjs/core';
import { messageLink, time, TimestampStyles } from '@discordjs/formatters';
import { DiscordSnowflake } from '@sapphire/snowflake';
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, ModCaseKind, type ModCase } from '../db.js';
import { computeAvatarUrl } from '../util/computeAvatar.js';
import { formatMessageToEmbed } from '../util/userMessageToEmbed.js';
import { userToEmbedAuthor } from '../util/userToEmbedData.js';
import { INotifier, type DMUserOptions, type HistoryEmbedOptions, type LogModCaseOptions } from './INotifier.js';

Expand Down Expand Up @@ -122,7 +124,7 @@
});
}

// TODO: Take in APIGuild?

Check warning on line 127 in packages/core/src/notifications/Notifier.ts

View workflow job for this annotation

GitHub Actions / Quality Check

Unexpected 'todo' comment: 'TODO: Take in APIGuild?'

Check warning on line 127 in packages/core/src/notifications/Notifier.ts

View workflow job for this annotation

GitHub Actions / Quality Check

Unexpected 'todo' comment: 'TODO: Take in APIGuild?'

Check warning on line 127 in packages/core/src/notifications/Notifier.ts

View workflow job for this annotation

GitHub Actions / Quality Check

Unexpected 'todo' comment: 'TODO: Take in APIGuild?'

Check warning on line 127 in packages/core/src/notifications/Notifier.ts

View workflow job for this annotation

GitHub Actions / Quality Check

Unexpected 'todo' comment: 'TODO: Take in APIGuild?'
public override async tryNotifyTargetModCase(modCase: Selectable<ModCase>): Promise<boolean> {
try {
const guild = await this.api.guilds.get(modCase.guildId);
Expand Down Expand Up @@ -202,4 +204,14 @@

return embed;
}

public override async logReport(guildId: Snowflake, message: APIMessage): Promise<void> {
const { reportChannelId } = await this.database.getSettings(guildId);
if (!reportChannelId) {
throw new Error('No report channel has been set up in this community; the caller is expected to assert this');
}

const embed = formatMessageToEmbed(message);
embed.color = 0xf04848;
}
}
11 changes: 11 additions & 0 deletions packages/core/src/util/computeModalFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { APIModalSubmission, ModalSubmitComponent } from '@discordjs/core';

export function computeModalFields(data: APIModalSubmission) {
return data.components.reduce((accumulator, next) => {
for (const component of next.components) {
accumulator.set(component.custom_id, component);
}

return accumulator;
}, new Map<string, ModalSubmitComponent>());
}
39 changes: 39 additions & 0 deletions packages/core/src/util/userMessageToEmbed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Sourced from https://github.com/Naval-Base/yuudachi/blob/e398023952eeb2451af2c29884d9b848a5051985/apps/yuudachi/src/functions/logging/formatMessageToEmbed.ts#L6

// Copyright (C) 2021 Noel Buechler

// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.

import { truncateEmbed } from '@chatsift/discord-utils';
import type { APIMessage } from '@discordjs/core';
import { DiscordSnowflake } from '@sapphire/snowflake';
import { userToEmbedAuthor } from './userToEmbedData.js';

export function formatMessageToEmbed(message: APIMessage) {
const embed = truncateEmbed({
author: userToEmbedAuthor(message.author, message.author.id),
description: message.content.length ? message.content : 'No content',
timestamp: new Date(DiscordSnowflake.timestampFrom(message.id)).toISOString(),
});

const attachment = message.attachments[0];

const attachmentIsImage = ['image/jpeg', 'image/png', 'image/gif'].includes(attachment?.content_type ?? '');
const attachmentIsImageNaive = ['.jpg', '.png', '.gif'].some((ext) => attachment?.filename?.endsWith(ext));

if (attachment && (attachmentIsImage || attachmentIsImageNaive)) {
embed.image = {
url: attachment.url,
};
}

return embed;
}
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,25 @@ model LogWebhook {
threadId String?
kind LogWebhookKind
}

model Settings {
guildId String @id
reportChannelId String?
}

model Report {
id Int @id @default(autoincrement())
reportMessageId String
acknowledged Boolean @default(false)

reporters Reporter[]
}

model Reporter {
reportId Int
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
userId String
reason String

@@id([reportId, userId])
}
2 changes: 0 additions & 2 deletions services/interactions/src/handlers/history.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { IDatabase, INotifier, type HandlerModule, type ICommandHandler } from '@automoderator/core';
import {
API,
ApplicationCommandOptionType,
ApplicationCommandType,
InteractionContextType,
Expand All @@ -19,7 +18,6 @@ export default class HistoryHandler implements HandlerModule<CoralInteractionHan
public constructor(
private readonly database: IDatabase,
private readonly notifier: INotifier,
private readonly api: API,
) {}

public register(handler: ICommandHandler<CoralInteractionHandler>) {
Expand Down
103 changes: 103 additions & 0 deletions services/interactions/src/handlers/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { HandlerModule, ICommandHandler, IDatabase, INotifier } from '@automoderator/core';
import { computeModalFields } from '@automoderator/core/src/util/computeModalFields.js';
import {
API,
ApplicationCommandType,
ComponentType,
InteractionContextType,
MessageFlags,
TextInputStyle,
type APIApplicationCommandInteraction,
type APIModalSubmitInteraction,
} from '@discordjs/core';
import type { InteractionOptionResolver } from '@sapphire/discord-utilities';
import { ActionKind, HandlerStep, type InteractionHandler as CoralInteractionHandler } from 'coral-command';
import { injectable } from 'inversify';

@injectable()
export default class ReportHandler implements HandlerModule<CoralInteractionHandler> {
public constructor(
private readonly database: IDatabase,
private readonly api: API,
private readonly notifier: INotifier,
) {}

public register(handler: ICommandHandler<CoralInteractionHandler>) {
handler.register({
interactions: [
{
name: 'Report Message',
type: ApplicationCommandType.Message,
contexts: [InteractionContextType.Guild],
},
],
applicationCommands: [['Report Message:none:none', this.handle.bind(this)]],
modals: [['report-modal', this.handleModal.bind(this)]],
});
}

public async *handle(
interaction: APIApplicationCommandInteraction,
options: InteractionOptionResolver,
): CoralInteractionHandler {
const { reportChannelId } = await this.database.getSettings(interaction.guild_id!);
if (!reportChannelId) {
yield* HandlerStep.from(
{
action: ActionKind.Reply,
options: {
content: 'No report channel has been set up in this community.',
flags: MessageFlags.Ephemeral,
},
},
true,
);
}

const message = options.getTargetMessage();

yield* HandlerStep.from({
action: ActionKind.OpenModal,
options: {
custom_id: `report-modal|${message.channel_id}|${message.id}`,
title: 'Report Message',
components: [
{
type: ComponentType.ActionRow,
components: [
{
label: 'Reason',
type: ComponentType.TextInput,
custom_id: 'report-reason',
required: true,
style: TextInputStyle.Paragraph,
},
],
},
],
},
});
}

private async *handleModal(
interaction: APIModalSubmitInteraction,
[channelId, messageId]: string[],
): CoralInteractionHandler {
if (!channelId || !messageId) {
throw new Error('Malformed custom_id');
}

yield* HandlerStep.from({
action: ActionKind.EnsureDeferReply,
options: {
content: 'Forwarding...',
},
});

const message = await this.api.channels.getMessage(channelId, messageId);
const fields = computeModalFields(interaction.data);
const reason = fields.get('report-reason')!;

const { reportChannelId } = await this.database.getSettings(interaction.guild_id!);
}
}
Loading
Loading