diff --git a/packages/core/package.json b/packages/core/package.json index 64718d44..6e31a80e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,7 @@ "inversify": "^6.0.1", "ioredis": "^5.3.2", "kysely": "^0.26.3", + "murmurhash": "^2.0.1", "pg": "^8.11.3", "pino": "^8.15.1", "pino-pretty": "^10.2.0", diff --git a/packages/core/src/actions/IModAction.ts b/packages/core/src/actions/IModAction.ts deleted file mode 100644 index 93fedc2b..00000000 --- a/packages/core/src/actions/IModAction.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Selectable } from 'kysely'; -import type { Case, RestrictCaseData, WarnCaseData } from '../db'; - -/** - * Base data required to create a case. - */ -export interface BaseCaseCreateData { - guildId: string; - modId: string; - reason: string | null; - targetId: string; -} - -/** - * Data required for restrict cases. - */ -// We use a type intersection here since `OptionalCaseCreateDurationData` is a union. -export interface RestrictCaseCreateData extends BaseCaseCreateData { - clean: boolean; - expiresAt: Date | null; - roleId: string; -} - -/** - * Structure responsible for preparation, execution, and notification of a mod action. - * - * Note that call order is important but not the same for all types of actions. There's cases where `execute` can cause - * the member to no longer in the guild, so `notify` must be called first. - */ -export interface IModAction { - /** - * Executes the action. - */ - execute(data: TIn): Promise; - /** - * Notifies the target of the action. - * - * @returns A boolean indicating whether the notification was successful. - */ - notify(data: TIn): Promise; -} - -export interface IRestrictModAction - extends IModAction & Selectable> {} - -export interface IUnrestrictModAction - extends IModAction & Selectable, Selectable> {} - -export interface IWarnCaseAction extends IModAction & WarnCaseData> {} diff --git a/packages/core/src/actions/README.md b/packages/core/src/actions/README.md deleted file mode 100644 index f1a39636..00000000 --- a/packages/core/src/actions/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# actions - -This directory contains structures responsible for dispatching moderation actions. - -Unlike other constructs in this project, we do not leverage a factory pattern here, this is because -the input to each action type is different, and as such we cannot return a generic `IModAction`. - -As such, each type gets its own interface, refer to the [`IModAction` file](./IModAction.ts). diff --git a/packages/core/src/actions/RestrictModAction.ts b/packages/core/src/actions/RestrictModAction.ts deleted file mode 100644 index d1023322..00000000 --- a/packages/core/src/actions/RestrictModAction.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { TimestampStyles, time } from '@discordjs/builders'; -import { API } from '@discordjs/core'; -import { inject, injectable } from 'inversify'; -import { Kysely } from 'kysely'; -import type { Selectable } from 'kysely'; -import { type Logger } from 'pino'; -import { INJECTION_TOKENS } from '../container.js'; -import { CaseAction, type Case, type DB, type RestrictCaseData, TaskType } from '../db.js'; -import { Util } from '../singletons/Util.js'; -import { sqlJson } from '../util/sqlJson.js'; -import type { IRestrictModAction, RestrictCaseCreateData } from './IModAction.js'; - -@injectable() -export class RestrictModAction implements IRestrictModAction { - public constructor( - private readonly api: API, - private readonly db: Kysely, - private readonly util: Util, - @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, - ) {} - - public async execute(data: RestrictCaseCreateData): Promise & Selectable> { - const member = await this.api.guilds.getMember(data.guildId, data.targetId); - const initialRoles = await this.util.getNonManagedMemberRoles(data.guildId, member); - - const roles = data.clean ? [data.roleId] : [...initialRoles, data.roleId]; - const undoRoles = data.clean ? initialRoles : []; - - await this.api.guilds.editMember(data.guildId, data.targetId, { roles }); - - return this.db.transaction().execute(async (transaction) => { - const cs = await transaction - .insertInto('Case') - .values({ - guildId: data.guildId, - targetId: data.targetId, - modId: data.modId, - actionType: CaseAction.restrict, - reason: data.reason, - }) - .returningAll() - .executeTakeFirst(); - - const roleData = await transaction - .insertInto('RestrictCaseData') - .values({ - id: cs!.id, - roleId: data.roleId, - clean: data.clean, - expiresAt: data.expiresAt, - }) - .returningAll() - .executeTakeFirst(); - - if (data.expiresAt) { - await transaction - .insertInto('Task') - .values({ - type: TaskType.undoTimedRoleCase, - guildId: data.guildId, - runAt: data.expiresAt, - data: sqlJson({ caseId: cs!.id }), - }) - .execute(); - } - - await transaction - .insertInto('UndoRestrictRole') - .values(undoRoles.map((roleId) => ({ caseId: cs!.id, roleId }))) - .execute(); - - return { ...cs!, ...roleData! }; - }); - } - - public async notify(data: RestrictCaseCreateData): Promise { - const guild = await this.api.guilds.get(data.guildId).catch((error) => { - this.logger.error(error, 'Failed to fetch guild in notify'); - return null; - }); - - if (!guild) { - return false; - } - - const lines: string[] = [ - `You have been restricted in ${guild.name}, the following role has been added to you: <@&${data.roleId}>`, - ]; - - if (data.reason) { - lines.push(`Reason: ${data.reason}`); - } - - if (data.expiresAt) { - lines.push( - `This punishment will expire at: ${time(data.expiresAt, TimestampStyles.LongDateTime)} (${time( - data.expiresAt, - TimestampStyles.RelativeTime, - )})`, - ); - } - - return this.util.tryDmUser(data.targetId, lines.join('\n'), data.guildId); - } -} diff --git a/packages/core/src/applicationData/IDataManager.ts b/packages/core/src/applicationData/IDataManager.ts new file mode 100644 index 00000000..d12f73ca --- /dev/null +++ b/packages/core/src/applicationData/IDataManager.ts @@ -0,0 +1,11 @@ +import type { Selectable } from 'kysely'; +import type { Experiment, ExperimentOverride } from '../db.js'; + +export type ExperimentWithOverrides = Selectable & { overrides: Selectable[] }; + +/** + * Abstraction over all database interactions (things like Redis included) + */ +export abstract class IDataManager { + public abstract getExperiments(): Promise; +} diff --git a/packages/core/src/applicationData/KyselyDataManager.ts b/packages/core/src/applicationData/KyselyDataManager.ts new file mode 100644 index 00000000..3d9249c9 --- /dev/null +++ b/packages/core/src/applicationData/KyselyDataManager.ts @@ -0,0 +1,34 @@ +import type { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import type { DB } from '../db.js'; +import { IDataManager, type ExperimentWithOverrides } from './IDataManager.js'; + +/** + * Our current databse implementation, using kysely with types generated by prisma-kysely + */ +export class KyselyDataManager extends IDataManager { + readonly #database: Kysely; + + // Note that we avoid using an actual dependency. This is because we don't really want to expose database + // into our container. An implementation over IDataHandler should always be used. + public constructor(database: Kysely) { + super(); + + this.#database = database; + } + + public override async getExperiments(): Promise { + return this.#database + .selectFrom('Experiment') + .selectAll() + .select((query) => [ + jsonArrayFrom( + query + .selectFrom('ExperimentOverride') + .selectAll('ExperimentOverride') + .whereRef('Experiment.name', '=', 'ExperimentOverride.experimentName'), + ).as('overrides'), + ]) + .execute(); + } +} diff --git a/packages/core/src/binary-encoding/README.md b/packages/core/src/binary-encoding/README.md index 069f7a85..6255d995 100644 --- a/packages/core/src/binary-encoding/README.md +++ b/packages/core/src/binary-encoding/README.md @@ -1,3 +1,3 @@ # binary-encoding -Custom encoding we use for storing cache. +Custom encoding we use for storing Discord data cache. diff --git a/packages/core/src/binary-encoding/RWFactory.ts b/packages/core/src/binary-encoding/RWFactory.ts deleted file mode 100644 index 2c20a78e..00000000 --- a/packages/core/src/binary-encoding/RWFactory.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Buffer } from 'node:buffer'; -import { injectable } from 'inversify'; -import type { IReader, IWriter } from './Data'; -import { Reader } from './Reader.js'; -import { Writer } from './Writer.js'; - -@injectable() -/** - * @remarks - * Obviously, those are not singletons. - */ -export class RWFactory { - public buildReader(data: Buffer): IReader { - return new Reader(data); - } - - public buildWriter(size: number): IWriter { - return new Writer(size); - } -} diff --git a/packages/core/src/broker-types/logging.ts b/packages/core/src/broker-types/logging.ts deleted file mode 100644 index 34bca08f..00000000 --- a/packages/core/src/broker-types/logging.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Selectable } from 'kysely'; -import type { Case } from '../db'; - -export enum GuildLogType { - AutomodTrigger = 'automod_triggers', - ModAction = 'mod_actions', - UserJoin = 'user_joins', - UserLeave = 'user_leaves', - UserNicknameUpdate = 'user_nickname_updates', - UserUsernameUpdate = 'user_username_updates', -} - -interface GuildLogBaseData { - guildId: string; -} - -export type GuildLogModActionData = GuildLogBaseData & { - cases: Selectable[]; -}; - -export interface GuildLogMap { - [GuildLogType.ModAction]: GuildLogModActionData; - [GuildLogType.AutomodTrigger]: never; - [GuildLogType.UserJoin]: never; - [GuildLogType.UserLeave]: never; - [GuildLogType.UserUsernameUpdate]: never; - [GuildLogType.UserNicknameUpdate]: never; -} diff --git a/packages/core/src/cache/CacheFactory.ts b/packages/core/src/cache/CacheFactory.ts index 642e48f0..aab09cec 100644 --- a/packages/core/src/cache/CacheFactory.ts +++ b/packages/core/src/cache/CacheFactory.ts @@ -1,11 +1,10 @@ -import { inject, injectable } from 'inversify'; +import { inject } from 'inversify'; import { Redis } from 'ioredis'; import { INJECTION_TOKENS } from '../container.js'; -import { Cache } from './Cache.js'; import type { ICache } from './ICache.js'; +import { RedisCache } from './RedisCache.js'; import type { ICacheEntity } from './entities/ICacheEntity'; -@injectable() /** * @remarks * Since this is a singleton factroy, we "cache our caches" in a WeakMap to avoid additional computation on subsequent calls. @@ -20,7 +19,7 @@ export class CacheFactory { return this.caches.get(entity)! as ICache; } - const cache = new Cache(this.redis, entity); + const cache = new RedisCache(this.redis, entity); this.caches.set(entity, cache); return cache; diff --git a/packages/core/src/cache/README.md b/packages/core/src/cache/README.md index caee4800..c6a983bd 100644 --- a/packages/core/src/cache/README.md +++ b/packages/core/src/cache/README.md @@ -1,6 +1,6 @@ # cache -This is where we deal with caching. A couple of things to note: +This is where we deal with Discord data caching. A couple of things to note: - we use our own encoding format for cache to make encoding/decoding and space usage as efficient as possible. Refer to the [`binary-encoding` directory](../binary-encoding) diff --git a/packages/core/src/cache/Cache.ts b/packages/core/src/cache/RedisCache.ts similarity index 96% rename from packages/core/src/cache/Cache.ts rename to packages/core/src/cache/RedisCache.ts index 23e6fce0..234abffd 100644 --- a/packages/core/src/cache/Cache.ts +++ b/packages/core/src/cache/RedisCache.ts @@ -7,7 +7,7 @@ import type { ICacheEntity } from './entities/ICacheEntity.js'; * This class is deliberately not an `@injectable()`, refer to the README for more information on the pattern * being used. */ -export class Cache implements ICache { +export class RedisCache implements ICache { public constructor( private readonly redis: Redis, private readonly entity: ICacheEntity, diff --git a/packages/core/src/cache/entities/GuildCacheEntity.ts b/packages/core/src/cache/entities/GuildCacheEntity.ts index 768b035e..0adf2061 100644 --- a/packages/core/src/cache/entities/GuildCacheEntity.ts +++ b/packages/core/src/cache/entities/GuildCacheEntity.ts @@ -1,6 +1,7 @@ import type { Buffer } from 'node:buffer'; import { injectable } from 'inversify'; -import { RWFactory } from '../../binary-encoding/RWFactory.js'; +import { Reader } from '../../binary-encoding/Reader.js'; +import { Writer } from '../../binary-encoding/Writer.js'; import type { ICacheEntity } from './ICacheEntity'; export interface CachedGuild { @@ -12,8 +13,6 @@ export interface CachedGuild { @injectable() export class GuildCacheEntity implements ICacheEntity { - public constructor(private readonly rwFactory: RWFactory) {} - public readonly TTL = 60_000; public makeKey(id: string): string { @@ -21,17 +20,11 @@ export class GuildCacheEntity implements ICacheEntity { } public toBuffer(guild: CachedGuild): Buffer { - return this.rwFactory - .buildWriter(200) - .u64(guild.id) - .string(guild.icon) - .string(guild.name) - .u64(guild.owner_id) - .dumpTrimmed(); + return new Writer(200).u64(guild.id).string(guild.icon).string(guild.name).u64(guild.owner_id).dumpTrimmed(); } public toJSON(data: Buffer): CachedGuild { - const reader = this.rwFactory.buildReader(data); + const reader = new Reader(data); const decoded: CachedGuild = { id: reader.u64()!.toString(), diff --git a/packages/core/src/container.ts b/packages/core/src/container.ts index fe9f0385..06bda2fd 100644 --- a/packages/core/src/container.ts +++ b/packages/core/src/container.ts @@ -6,12 +6,13 @@ export const globalContainer = new Container({ }); export const INJECTION_TOKENS = { - redis: Symbol('redis instance'), logger: Symbol('logger instance'), + /** + * @remarks + * Not to be used explicitly ever. There should always be abstraction classes for interactions with redis + */ + redis: Symbol('redis instance'), cacheEntities: { guild: Symbol('guild cache entity'), }, - actions: { - restrict: Symbol('restrict action'), - }, } as const; diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 9e897146..e6fefb09 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -4,92 +4,20 @@ export type Generated = T extends ColumnType : ColumnType; export type Timestamp = ColumnType; -export const LogChannelType = { - mod: 'mod', - filter: 'filter', - user: 'user', - message: 'message', -} as const; -export type LogChannelType = (typeof LogChannelType)[keyof typeof LogChannelType]; -export const CaseAction = { - restrict: 'restrict', - unrestrict: 'unrestrict', - warn: 'warn', - timeout: 'timeout', - revokeTimeout: 'revokeTimeout', - kick: 'kick', - softban: 'softban', - ban: 'ban', - unban: 'unban', -} as const; -export type CaseAction = (typeof CaseAction)[keyof typeof CaseAction]; -export const TaskType = { - undoTimedRoleCase: 'undoTimedRoleCase', -} as const; -export type TaskType = (typeof TaskType)[keyof typeof TaskType]; -export type BanCaseData = { - id: number; - deleteMessageDays: number | null; - expiresAt: Timestamp | null; -}; -export type Case = { - id: Generated; - guildId: string; - logChannelId: string | null; - logMessageId: string | null; - targetId: string; - modId: string | null; - actionType: CaseAction; - reason: string | null; +export type Experiment = { + name: string; createdAt: Generated; + updatedAt: Timestamp | null; + rangeStart: number; + rangeEnd: number; + active: Generated; }; -export type CaseReference = { - caseId: number; - refId: number; -}; -export type LogChannelWebhook = { - guildId: string; - logType: LogChannelType; - channelId: string; - webhookId: string; - webhookToken: string; - threadId: string | null; -}; -export type ModRole = { - guildId: string; - roleId: string; -}; -export type RestrictCaseData = { - id: number; - roleId: string; - clean: Generated; - expiresAt: Timestamp | null; -}; -export type Task = { +export type ExperimentOverride = { id: Generated; - type: TaskType; guildId: string; - createdAt: Generated; - runAt: Timestamp; - attempts: Generated; - data: unknown; -}; -export type UndoRestrictRole = { - caseId: number; - roleId: string; -}; -export type WarnCaseData = { - id: number; - pardonedById: string | null; + experimentName: string; }; export type DB = { - BanCaseData: BanCaseData; - Case: Case; - CaseReference: CaseReference; - LogChannelWebhook: LogChannelWebhook; - ModRole: ModRole; - RestrictCaseData: RestrictCaseData; - Task: Task; - UndoRestrictRole: UndoRestrictRole; - WarnCaseData: WarnCaseData; + Experiment: Experiment; + ExperimentOverride: ExperimentOverride; }; diff --git a/packages/core/src/experiments/ExperimentHandler.ts b/packages/core/src/experiments/ExperimentHandler.ts new file mode 100644 index 00000000..6da8a51d --- /dev/null +++ b/packages/core/src/experiments/ExperimentHandler.ts @@ -0,0 +1,56 @@ +import { setInterval } from 'node:timers'; +import { inject, injectable } from 'inversify'; +import murmurhash from 'murmurhash'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type { Logger } from 'pino'; +import { IDataManager, type ExperimentWithOverrides } from '../applicationData/IDataManager.js'; +import { INJECTION_TOKENS } from '../container.js'; +import { IExperimentHandler } from './IExperimentHandler.js'; + +@injectable() +export class ExperimentHandler extends IExperimentHandler { + readonly #experimentCache = new Map(); + + public constructor( + private readonly dataManager: IDataManager, + @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, + ) { + super(); + + void this.poll(); + setInterval(async () => this.poll(), 180_000).unref(); + } + + public guildIsInExperiment(guildId: string, experimentName: string): boolean { + const experiment = this.#experimentCache.get(experimentName); + if (!experiment) { + this.logger.warn( + { guildId, experimentName }, + 'Ran experiment check for an unknown experiment. Perhaps cache is out of date?', + ); + + return false; + } + + const isOverriden = experiment.overrides.some((experiment) => experiment.guildId === guildId); + if (isOverriden) { + return true; + } + + const hash = this.computeExperimentHash(experimentName, guildId); + return hash >= experiment.rangeStart && hash < experiment.rangeEnd; + } + + private async poll(): Promise { + const experiments = await this.dataManager.getExperiments(); + + this.#experimentCache.clear(); + for (const experiment of experiments) { + this.#experimentCache.set(experiment.name, experiment); + } + } + + private computeExperimentHash(name: string, guildId: string): number { + return murmurhash.v3(`${name}:${guildId}`) % 1e4; + } +} diff --git a/packages/core/src/experiments/IExperimentHandler.ts b/packages/core/src/experiments/IExperimentHandler.ts new file mode 100644 index 00000000..1cbd6496 --- /dev/null +++ b/packages/core/src/experiments/IExperimentHandler.ts @@ -0,0 +1,10 @@ +/** + * Deals with "experiments" in the app. Those can serve as complete feature flags or just as a way to progressively + * roll out a feature. + */ +export abstract class IExperimentHandler { + /** + * Checks if a given guild is in a given experiment. + */ + public abstract guildIsInExperiment(guildId: string, experimentName: string): boolean; +} diff --git a/packages/core/src/experiments/README.md b/packages/core/src/experiments/README.md new file mode 100644 index 00000000..25b2df68 --- /dev/null +++ b/packages/core/src/experiments/README.md @@ -0,0 +1,10 @@ +# experiments + +This directory contains handling of "experiments" across the entire stack. This is how we run on "every code push hits prod". + +We generate a "hash" (value between 0 and 9999) from the experiment name and the guild ID. The database holds experiment +configuration data (ranges and overrides), which is how we determine if a guild is part of an experiment with minimal database hits. + +Note: Naming convention for experiments is EXPERIMENT_NAME_[YYYY]_[MM] + +Note: Experiment data is re-polled ever 3 minutes. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e92ada52..7a7c58af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,37 +1,26 @@ -// Deliberately do not export any of the implementations. They are meant to be injected via symbols/tokens -// as to not be impl specific. -export * from './actions/IModAction.js'; +// Deliberately don't export impl +export * from './applicationData/IDataManager.js'; export * from './binary-encoding/Data.js'; export * from './binary-encoding/Reader.js'; -export * from './binary-encoding/RWFactory.js'; export * from './binary-encoding/Writer.js'; export * from './broker-types/gateway.js'; -export * from './broker-types/logging.js'; -// Same here. +// Deliberately don't export impl export * from './cache/entities/ICacheEntity.js'; -// Same here. +// Deliberately don't export impl export * from './cache/CacheFactory.js'; export * from './cache/ICache.js'; export * from './singletons/DependencyManager.js'; export * from './singletons/Env.js'; -export * from './singletons/LogEmbedBuilder.js'; -export * from './singletons/Util.js'; - -// Same here. -export * from './userActionValidators/IUserActionValidator.js'; -export * from './userActionValidators/UserActionValidatorFactory.js'; export * from './util/encode.js'; -export * from './util/factoryFrom.js'; export * from './util/parseRelativeTime.js'; export * from './util/PermissionsBitField.js'; export * from './util/promiseAllObject.js'; -export * from './util/sqlJson.js'; export * from './container.js'; export * from './db.js'; diff --git a/packages/core/src/singletons/CaseManager.ts b/packages/core/src/singletons/CaseManager.ts deleted file mode 100644 index 1ebf756c..00000000 --- a/packages/core/src/singletons/CaseManager.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { API } from '@discordjs/core'; -import { inject, injectable } from 'inversify'; -import { Kysely, type Selectable } from 'kysely'; -import { CaseAction, TaskType, type DB, type Case } from '../db.js'; -import { sqlJson } from '../util/sqlJson.js'; -import { Util } from './Util.js'; - -export type OptionalCaseCreateDurationData = - | { - duration: null; - expiresAt: null; - } - | { - duration: number; - expiresAt: Date; - }; - -export interface BaseCaseCreateData { - guildId: string; - modId: string; - reason?: string; - targetId: string; -} - -export interface RoleCaseCreateData extends BaseCaseCreateData { - clean: boolean; - roleId: string; -} - -@injectable() -export class CaseManager { - public constructor( - private readonly api: API, - @inject(Kysely) private readonly db: Kysely, - private readonly util: Util, - ) {} - - public async unrole(cs: Selectable, data: Selectable): Promise { - const member = await this.api.guilds.getMember(cs.guildId, cs.targetId); - const initialRoles = await this.util.getNonManagedMemberRoles(cs.guildId, member); - - const undoRoles = await this.db - .selectFrom('UndoRestrictRole') - .select('roleId') - .where('caseId', '=', cs.id) - .execute(); - const roles = data.clean ? [...initialRoles, ...undoRoles.map((role) => role.roleId)] : initialRoles; - - await this.api.guilds.editMember(cs.guildId, cs.targetId, { roles: roles.filter((role) => role !== data.roleId) }); - - await this.db.transaction().execute(async (transaction) => { - await transaction - .insertInto('Case') - .values({ - guildId: cs.guildId, - targetId: cs.targetId, - modId: data.modId, - actionType: CaseAction.unrestrict, - reason: data.reason, - }) - .execute(); - - await transaction.deleteFrom('UndoRestrictRole').where('caseId', '=', cs.id).execute(); - - await transaction - .deleteFrom('Task') - .where('data', '@>', sqlJson({ caseId: cs.id })) - .execute(); - }); - } - - public async warn(data: BaseCaseCreateData): Promise { - await this.db.transaction().execute(async (transaction) => { - const cs = await transaction - .insertInto('Case') - .values({ - guildId: data.guildId, - targetId: data.targetId, - modId: data.modId, - actionType: CaseAction.warn, - reason: data.reason, - }) - .returning('id') - .executeTakeFirst(); - - await transaction - .insertInto('WarnCaseData') - .values({ - id: cs!.id, - }) - .execute(); - }); - } -} diff --git a/packages/core/src/singletons/DependencyManager.ts b/packages/core/src/singletons/DependencyManager.ts index d60bde88..eecc1b2f 100644 --- a/packages/core/src/singletons/DependencyManager.ts +++ b/packages/core/src/singletons/DependencyManager.ts @@ -5,8 +5,6 @@ import { Redis } from 'ioredis'; import { Kysely, PostgresDialect } from 'kysely'; import type { Logger } from 'pino'; import createPinoLogger from 'pino'; -import type { IRestrictModAction } from '../actions/IModAction.js'; -import { RestrictModAction } from '../actions/RestrictModAction.js'; import { GuildCacheEntity, type CachedGuild } from '../cache/entities/GuildCacheEntity.js'; import type { ICacheEntity } from '../cache/entities/ICacheEntity.js'; import { INJECTION_TOKENS, globalContainer } from '../container.js'; @@ -31,6 +29,7 @@ const { */ export class DependencyManager { public constructor(private readonly env: Env) { + this.registerLogger(); this.registerStructures(); } @@ -50,12 +49,6 @@ export class DependencyManager { return api; } - public registerLogger(): Logger { - const logger = createPinoLogger({ level: 'trace' }); - globalContainer.bind(INJECTION_TOKENS.logger).toConstantValue(logger); - return logger; - } - public registerDatabase(): Kysely { const database = new Kysely({ dialect: new PostgresDialect({ @@ -73,17 +66,16 @@ export class DependencyManager { return database; } + private registerLogger(): void { + const logger = createPinoLogger({ level: 'trace' }); + globalContainer.bind(INJECTION_TOKENS.logger).toConstantValue(logger); + } + private registerStructures(): void { // cache entities globalContainer .bind>(INJECTION_TOKENS.cacheEntities.guild) .to(GuildCacheEntity) .inSingletonScope(); - - // actions - globalContainer - .bind(INJECTION_TOKENS.actions.restrict) - .to(RestrictModAction) - .inSingletonScope(); } } diff --git a/packages/core/src/singletons/Env.ts b/packages/core/src/singletons/Env.ts index c197066e..11b80107 100644 --- a/packages/core/src/singletons/Env.ts +++ b/packages/core/src/singletons/Env.ts @@ -26,18 +26,6 @@ export class Env { public readonly postgresDatabase: string = process.env.POSTGRES_DATABASE!; - /** - * @remarks - * null means the current process is not a task runner - */ - public readonly taskRunnerId: number | null = process.env.TASK_RUNNER_ID ? Number(process.env.TASK_RUNNER_ID) : null; - - /** - * @remarks - * Indicates how many task runners the stack is running - */ - public readonly taskRunnerConcurrency: number = Number(process.env.TASK_RUNNER_CONCURRENCY ?? '1'); - private readonly REQUIRED_KEYS = [ 'DISCORD_TOKEN', 'DISCORD_CLIENT_ID', @@ -47,7 +35,6 @@ export class Env { 'POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DATABASE', - 'TASK_RUNNER_CONCURRENCY', ] as const; public constructor() { diff --git a/packages/core/src/singletons/LogEmbedBuilder.ts b/packages/core/src/singletons/LogEmbedBuilder.ts deleted file mode 100644 index 6645c7c8..00000000 --- a/packages/core/src/singletons/LogEmbedBuilder.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { EmbedBuilder, inlineCode } from '@discordjs/builders'; -import type { APIEmbed, APIUser } from '@discordjs/core'; -import { injectable } from 'inversify'; -import type { Selectable } from 'kysely'; -import type { Case } from '../db.js'; -import { Util } from './Util.js'; - -export interface BuildModActionLogOptions { - cs: Selectable; - existingEmbed?: APIEmbed | null; - mod?: APIUser | null; - pardonedBy?: APIUser | null; - refCases?: Selectable[] | null; - referencedBy?: Selectable[] | null; - user?: APIUser | null; -} - -// TODO: Reconsider pattern -@injectable() -export class LogEmbedBuilder { - public constructor(private readonly util: Util) {} - - public readonly caseLogColors = { - restrict: 16_022_395, - unrestrict: 5_793_266, - warn: 16_022_395, - timeout: 16_022_395, - revokeTimeout: 5_793_266, - kick: 16_022_395, - softban: 16_022_395, - ban: 15_747_144, - unban: 5_793_266, - } as const; - - // TODO: Full support for all fields - public buildModActionLog({ cs, mod, user, existingEmbed, pardonedBy }: BuildModActionLogOptions): APIEmbed { - const builder = new EmbedBuilder(existingEmbed ?? {}) - .setColor(this.caseLogColors[cs.actionType]) - .setAuthor({ - name: `${this.util.getUserTag(user)} (${cs.targetId})`, - iconURL: this.util.getUserAvatarURL(user), - }) - .setFooter( - cs.modId - ? { - text: `Case #${cs.id} | By ${this.util.getUserTag(mod)} (${cs.modId})`, - iconURL: this.util.getUserAvatarURL(mod), - } - : null, - ); - - const description = [ - `**Action**: ${cs.actionType}`, - `**Reason**: ${cs.reason ?? `set a reason using ${inlineCode(`/case reason ${cs.id}`)}`}`, - ]; - - if (pardonedBy) { - description.push(`**Pardoned By**: ${pardonedBy.username}#${pardonedBy.discriminator} (${pardonedBy.id})`); - } - - builder.setDescription(description.join('\n')); - return builder.toJSON(); - } -} diff --git a/packages/core/src/singletons/README.md b/packages/core/src/singletons/README.md deleted file mode 100644 index 2715d518..00000000 --- a/packages/core/src/singletons/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# singletons - -General purpose "pure" singletons (decorated with `@injectable()`, bound to the dependency container implicitly) the first -time they are needed in a resolution tree. diff --git a/packages/core/src/singletons/Util.ts b/packages/core/src/singletons/Util.ts deleted file mode 100644 index d8c4c9eb..00000000 --- a/packages/core/src/singletons/Util.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { API } from '@discordjs/core'; -import type { APIGuildMember, APIUser } from '@discordjs/core'; -import { injectable } from 'inversify'; - -@injectable() -export class Util { - public constructor(private readonly api: API) {} - - public async getNonManagedMemberRoles(guildId: string, member: APIGuildMember): Promise { - const guildRoles = await this.api.guilds.getRoles(guildId); - - const memberRoles = member.roles; - const nonManagedRoles: string[] = []; - - for (const roleId of memberRoles) { - const role = guildRoles.find((role) => role.id === roleId); - if (role && !role.managed) { - nonManagedRoles.push(roleId); - } - } - - return nonManagedRoles; - } - - public async tryDmUser(userId: string, content: string, guildId?: string): Promise { - if (guildId) { - const member = await this.api.guilds.getMember(guildId, userId).catch(() => null); - if (!member) { - return false; - } - } - - const dmChannel = await this.api.users.createDM(userId).catch(() => null); - if (!dmChannel) { - return false; - } - - const message = await this.api.channels.createMessage(dmChannel.id, { content }).catch(() => null); - return Boolean(message); - } - - public getUserTag(user?: APIUser | null): string { - if (!user) { - return '[Deleted User]'; - } - - return `${user.username}#${user.discriminator}`; - } - - public getUserAvatarURL(user?: APIUser | null): string | undefined { - if (!user) { - return; - } - - return user.avatar - ? this.api.rest.cdn.avatar(user.id, user.avatar, { extension: 'webp' }) - : this.api.rest.cdn.defaultAvatar(Number.parseInt(user.discriminator, 10) % 5); - } -} diff --git a/packages/core/src/userActionValidators/IUserActionValidator.ts b/packages/core/src/userActionValidators/IUserActionValidator.ts deleted file mode 100644 index 16f79a0d..00000000 --- a/packages/core/src/userActionValidators/IUserActionValidator.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - APIGuild, - APIGuildMember, - APIInteractionDataResolvedGuildMember, - APIRole, - APIUser, -} from '@discordjs/core'; - -export type UserActionValidatorTarget = - | APIGuildMember - | APIUser - | string - | (APIInteractionDataResolvedGuildMember & { user: APIUser }); - -export interface UserActionValidatorContext { - guild: APIGuild | string; - guildRoles?: APIRole[] | null; - moderator: APIGuildMember; - target: UserActionValidatorTarget; -} - -export type UserActionValidatorResult = - | { - ok: false; - reason: string; - } - | { - ok: true; - }; - -/** - * Structure responsible for assisting in determining if a certain operation with a user target is allowed. - */ -export interface IUserActionValidator { - /** - * Determines if the moderator is hiarchically allowed to perform an action on a target user, - * and if the target user is actionable (i.e. is not a moderator themselves). - */ - targetIsActionable(): Promise; -} diff --git a/packages/core/src/userActionValidators/README.md b/packages/core/src/userActionValidators/README.md deleted file mode 100644 index d5c1310b..00000000 --- a/packages/core/src/userActionValidators/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# userActionValidators - -Structure useful in validating if a given user can execute an action on a given target. - -Once more, a factory pattern is adopted here because we need data input in the constructor. diff --git a/packages/core/src/userActionValidators/UserActionValidator.ts b/packages/core/src/userActionValidators/UserActionValidator.ts deleted file mode 100644 index 9bb051fe..00000000 --- a/packages/core/src/userActionValidators/UserActionValidator.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { - API, - APIGuild, - APIGuildMember, - APIInteractionDataResolvedGuildMember, - APIRole, - APIUser, -} from '@discordjs/core'; -import type { Kysely } from 'kysely'; -import type { DB } from '../db'; -import type { - IUserActionValidator, - UserActionValidatorContext, - UserActionValidatorResult, - UserActionValidatorTarget, -} from './IUserActionValidator'; - -function targetIsAPIGuildMember( - target: UserActionValidatorTarget, -): target is APIGuildMember | (APIInteractionDataResolvedGuildMember & { user: APIUser }) { - return typeof target !== 'string' && 'roles' in target; -} - -/** - * Current implementation of our action validation, built to prevent re-fetching data as much as possible. - */ -export class UserActionValidator implements IUserActionValidator { - private readonly guildId: string; - - private guild: APIGuild | null; - - /** - * @remarks - * We shhould always have the full moderator available. - */ - private readonly moderator: APIGuildMember; - - /** - * @remarks - * It's possible we could be dealing with a user that's not in the guild, or even potentially - * an account that no longer exists - */ - private readonly targetId: string; - - private target: Exclude | null; - - private guildRoles: APIRole[] | null; - - public constructor( - private readonly api: API, - private readonly db: Kysely, - context: UserActionValidatorContext, - ) { - this.guildId = typeof context.guild === 'string' ? context.guild : context.guild.id; - - this.guild = typeof context.guild === 'string' ? null : context.guild; - - this.moderator = context.moderator; - - if (typeof context.target === 'string') { - this.targetId = context.target; - this.target = null; - } else { - if (targetIsAPIGuildMember(context.target)) { - this.targetId = context.target.user!.id; - } else { - this.targetId = context.target.id; - } - - this.target = context.target; - } - - this.guildRoles = context.guildRoles ?? null; - } - - public async targetIsActionable(): Promise { - if (!this.guildRoles) { - this.guildRoles = await this.api.guilds.getRoles(this.guildId); - } - - const target = await this.assertTarget(); - // This operation is redundant as null implies the user no longer exists, - // but should probably go through anyway. We also have no extra state to update. - if (!target) { - return { ok: true }; - } - - // Target is not in the guild, role hiararchy does not apply. - if (!targetIsAPIGuildMember(target)) { - return { ok: true }; - } - - const guild = await this.assertGuild(); - - const modRoles = await this.db.selectFrom('ModRole').select('roleId').where('guildId', '=', this.guildId).execute(); - const modRoleIds = modRoles.map((role) => role.roleId); - - // If the target is a moderator, they cannot be acted on. - if (target.roles.some((role) => modRoleIds.includes(role))) { - return { ok: false, reason: 'Target appears to be a moderator.' }; - } - - // If the moderator is the owner, they bypass all further role hierarchy checks. - if (guild.owner_id === this.moderator.user!.id) { - return { ok: true }; - } - - // If the target is the owner, they cannot be lower than the moderator. - if (guild.owner_id === target.user!.id) { - return { ok: false, reason: 'Target appears to be the owner of this guild.' }; - } - - // Mod cannot act on themselves - if (this.moderator.user!.id === target.user!.id) { - return { ok: false, reason: 'You cannot act on yourself.' }; - } - - const highestModeratorRole = await this.getHighestRoleForUser(this.moderator.roles); - const highestTargetRole = await this.getHighestRoleForUser(target.roles); - - if (highestModeratorRole.position > highestTargetRole.position) { - return { ok: true }; - } - - return { ok: false, reason: 'Target appears to have a higher role than you.' }; - } - - private async getHighestRoleForUser(roles: string[]): Promise { - if (!this.guildRoles) { - this.guildRoles = await this.api.guilds.getRoles(this.guildId); - } - - const [role] = this.guildRoles.filter((role) => roles.includes(role.id)).sort((a, b) => b.position - a.position); - return role!; - } - - private async assertTarget(): Promise< - APIGuildMember | APIUser | (APIInteractionDataResolvedGuildMember & { user: APIUser }) | null - > { - if (this.target) { - return this.target; - } - - const user = await this.api.users.get(this.targetId).catch(() => null); - // Assume the error is due to the user no longer existing. - if (!user) { - return null; - } - - // Try to get the member from the guild. If it fails, assume the user is not in the guild. - const member = await this.api.guilds.getMember(this.guildId, this.targetId).catch(() => null); - - // Update our state - this.target = member ?? user; - return member ?? user; - } - - private async assertGuild(): Promise { - if (this.guild) { - return this.guild; - } - - const guild = await this.api.guilds.get(this.guildId); - this.guild = guild; - return guild; - } -} diff --git a/packages/core/src/userActionValidators/UserActionValidatorFactory.ts b/packages/core/src/userActionValidators/UserActionValidatorFactory.ts deleted file mode 100644 index 8713cde7..00000000 --- a/packages/core/src/userActionValidators/UserActionValidatorFactory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { API } from '@discordjs/core'; -import { injectable } from 'inversify'; -import { Kysely } from 'kysely'; -import type { DB } from '../db'; -import type { IUserActionValidator, UserActionValidatorContext } from './IUserActionValidator'; -import { UserActionValidator } from './UserActionValidator.js'; - -@injectable() -export class UserActionValidatorFactory { - public constructor( - private readonly api: API, - private readonly db: Kysely, - ) {} - - public build(context: UserActionValidatorContext): IUserActionValidator { - return new UserActionValidator(this.api, this.db, context); - } -} diff --git a/packages/core/src/util/factoryFrom.ts b/packages/core/src/util/factoryFrom.ts deleted file mode 100644 index 2f62eae2..00000000 --- a/packages/core/src/util/factoryFrom.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Transforms a class constructor type into a factory function type. - * - * @example - * ```ts - * class Foo { - * public constructor(public readonly bar: string) {} - * - * public baz(): string { - * return this.bar; - * } - * } - * - * type FooFactory = FactoryFrom; - * - * // elsewhere - * class Bar { - * public constructor(public readonly fooFactory: FooFactory) {} - * - * public doSomething(): void { - * const foo = this.fooFactory('hello'); - * console.log(foo.baz()); - * } - * } - * ``` - */ -export type FactoryFrom any> = TConstructor extends new ( - ...args: infer TArgs -) => infer TInstance - ? (...args: TArgs) => TInstance - : never; diff --git a/packages/core/src/util/sqlJson.ts b/packages/core/src/util/sqlJson.ts deleted file mode 100644 index 2970a7c5..00000000 --- a/packages/core/src/util/sqlJson.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sql, type RawBuilder } from 'kysely'; - -/** - * Helper function to cast a value to JSON in a SQL query. - * - * @remarks - * See https://kysely.dev/docs/recipes/extending-kysely#expression - */ -export function sqlJson(value: T): RawBuilder { - return sql`CAST(${JSON.stringify(value)} AS JSONB)`; -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41473288..28891936 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,110 +9,21 @@ datasource db { url = env("DATABASE_URL") } -model ModRole { - guildId String - roleId String - - @@id([guildId, roleId]) -} - -enum LogChannelType { - mod - filter - user - message -} - -model LogChannelWebhook { - guildId String - logType LogChannelType - channelId String - webhookId String - webhookToken String - threadId String? - - @@id([guildId, logType]) -} - -enum CaseAction { - restrict - unrestrict - warn - timeout - revokeTimeout - kick - softban - ban - unban -} - -model CaseReference { - caseId Int - case Case @relation("referenced", fields: [caseId], references: [id]) - refId Int - refCase Case @relation("references", fields: [refId], references: [id]) - - @@id([caseId, refId]) -} - -model Case { - id Int @id @default(autoincrement()) - guildId String - logChannelId String? - logMessageId String? - referencedBy CaseReference[] @relation("referenced") - references CaseReference[] @relation("references") - targetId String - modId String? - actionType CaseAction - reason String? - createdAt DateTime @default(now()) @db.Timestamptz(6) - - roleData RestrictCaseData? - warnData WarnCaseData? - banCaseData BanCaseData? -} - -model UndoRestrictRole { - caseId Int - caseData RestrictCaseData? @relation(fields: [caseId], references: [id], onDelete: Cascade) - roleId String - - @@id([caseId, roleId]) -} - -model RestrictCaseData { - id Int @id - case Case @relation(fields: [id], references: [id], onDelete: Cascade) - roleId String - clean Boolean @default(false) - undoRoles UndoRestrictRole[] - expiresAt DateTime? @db.Timestamptz(6) -} - -model WarnCaseData { - id Int @id - case Case @relation(fields: [id], references: [id], onDelete: Cascade) - pardonedById String? -} - -model BanCaseData { - id Int @id - case Case @relation(fields: [id], references: [id], onDelete: Cascade) - deleteMessageDays Int? - expiresAt DateTime? @db.Timestamptz(6) -} - -enum TaskType { - undoTimedRoleCase -} - -model Task { - id Int @id @default(autoincrement()) - type TaskType - guildId String - createdAt DateTime @default(now()) @db.Timestamptz(6) - runAt DateTime @db.Timestamptz(6) - attempts Int @default(0) - data Json +model Experiment { + name String @id + createdAt DateTime @default(now()) + updatedAt DateTime? + rangeStart Int + rangeEnd Int + active Boolean @default(true) + overrides ExperimentOverride[] +} + +model ExperimentOverride { + id Int @id @default(autoincrement()) + guildId String + experimentName String + experiment Experiment @relation(fields: [experimentName], references: [name]) + + @@unique([guildId, experimentName]) } diff --git a/services/discord-proxy/src/index.ts b/services/discord-proxy/src/index.ts index de439f7f..b0c2cc06 100644 --- a/services/discord-proxy/src/index.ts +++ b/services/discord-proxy/src/index.ts @@ -2,9 +2,7 @@ import 'reflect-metadata'; import { DependencyManager, globalContainer } from '@automoderator/core'; import { ProxyServer } from './server.js'; -// We don't need the default REST/API instance or prisma -const dependencyManager = globalContainer.get(DependencyManager); -dependencyManager.registerRedis(); -dependencyManager.registerLogger(); +const _dependencyManager = globalContainer.get(DependencyManager); -globalContainer.get(ProxyServer).listen(); +const server = globalContainer.get(ProxyServer); +server.listen(8_000); diff --git a/services/discord-proxy/src/server.ts b/services/discord-proxy/src/server.ts index 02ff1867..d4a10eab 100644 --- a/services/discord-proxy/src/server.ts +++ b/services/discord-proxy/src/server.ts @@ -1,95 +1,91 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer, type Server } from 'node:http'; import { URL } from 'node:url'; import { Env, INJECTION_TOKENS } from '@automoderator/core'; import { populateErrorResponse } from '@discordjs/proxy'; -import { - REST, - RequestMethod, - parseResponse, - type RouteLike, - DiscordAPIError, - HTTPError, - RateLimitError, -} from '@discordjs/rest'; +import { REST, RequestMethod, parseResponse, type RouteLike } from '@discordjs/rest'; import { inject, injectable } from 'inversify'; import { type Logger } from 'pino'; import { ProxyCache } from './cache.js'; @injectable() export class ProxyServer { - private readonly server: Server; + readonly #rest = new REST({ rejectOnRateLimit: () => true, retries: 0 }).setToken(this.env.discordToken); + + readonly #httpServer: Server; public constructor( @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, private readonly cache: ProxyCache, private readonly env: Env, ) { - const rest = new REST({ rejectOnRateLimit: () => true, retries: 0 }).setToken(this.env.discordToken); - this.server = createServer(async (req, res) => { - const { method, url } = req as { method: RequestMethod; url: string }; + this.#httpServer = createServer(async (req, res) => this.handleRequest(req, res)); + } - const parsedUrl = new URL(url, 'http://noop'); - // eslint-disable-next-line prefer-named-capture-group - const fullRoute = parsedUrl.pathname.replace(/^\/api(\/v\d+)?/, '') as RouteLike; + public listen(port: number) { + this.#httpServer.listen(port); + } - if (method === RequestMethod.Get) { - const cached = await this.cache.fetch(fullRoute); - this.logger.debug(cached, 'cache hit'); - if (cached !== null) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - return res.end(JSON.stringify(cached)); - } - } + private async handleRequest(req: IncomingMessage, res: ServerResponse) { + const { method, url } = req as { method: RequestMethod; url: string }; - const headers: Record = { - 'Content-Type': req.headers['content-type']!, - }; + const parsedUrl = new URL(url, 'http://noop'); + // eslint-disable-next-line prefer-named-capture-group + const fullRoute = parsedUrl.pathname.replace(/^\/api(\/v\d+)?/, '') as RouteLike; - if (req.headers.authorization) { - headers.authorization = req.headers.authorization; + if (method === RequestMethod.Get) { + const cached = await this.cache.fetch(fullRoute); + this.logger.debug(cached, 'cache hit'); + if (cached !== null) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + return res.end(JSON.stringify(cached)); } + } - try { - const discordResponse = await rest.queueRequest({ - body: req, - fullRoute, - method, - auth: false, - passThroughBody: true, - query: parsedUrl.searchParams, - headers, - }); + const headers: Record = { + 'Content-Type': req.headers['content-type']!, + }; - res.statusCode = discordResponse.status; + if (req.headers.authorization) { + headers.authorization = req.headers.authorization; + } - for (const [header, value] of discordResponse.headers) { - // Strip ratelimit headers - if (/^x-ratelimit/i.test(header)) { - continue; - } + try { + const discordResponse = await this.#rest.queueRequest({ + body: req, + fullRoute, + method, + auth: false, + passThroughBody: true, + query: parsedUrl.searchParams, + headers, + }); - res.setHeader(header, value); - } + res.statusCode = discordResponse.status; - const data = await parseResponse(discordResponse); - this.logger.debug(data, 'response'); - res.write(JSON.stringify(data)); - - await this.cache.update(fullRoute, data); - } catch (error) { - this.logger.error({ err: error, fullRoute, method }, 'Something went wrong'); - const knownError = populateErrorResponse(res, error); - if (!knownError) { - throw error; + for (const [header, value] of discordResponse.headers) { + // Strip ratelimit headers + if (/^x-ratelimit/i.test(header)) { + continue; } - } finally { - res.end(); + + res.setHeader(header, value); } - }); - } - public listen() { - this.server.listen(8_000); + const data = await parseResponse(discordResponse); + this.logger.debug(data, 'response'); + res.write(JSON.stringify(data)); + + await this.cache.update(fullRoute, data); + } catch (error) { + this.logger.error({ err: error, fullRoute, method }, 'Something went wrong'); + const knownError = populateErrorResponse(res, error); + if (!knownError) { + throw error; + } + } finally { + res.end(); + } } } diff --git a/services/gateway/src/gateway.ts b/services/gateway/src/gateway.ts index 0d51c11f..2e4f2097 100644 --- a/services/gateway/src/gateway.ts +++ b/services/gateway/src/gateway.ts @@ -9,9 +9,9 @@ import { type Logger } from 'pino'; @injectable() export class Gateway { - private readonly broker: PubSubRedisBroker; + readonly #broker: PubSubRedisBroker; - private readonly gateway: WebSocketManager; + readonly #gateway: WebSocketManager; public constructor( private readonly env: Env, @@ -19,13 +19,13 @@ export class Gateway { @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, @inject(INJECTION_TOKENS.redis) private readonly redis: Redis, ) { - this.broker = new PubSubRedisBroker({ + this.#broker = new PubSubRedisBroker({ redisClient: this.redis, encode, decode, }); - this.gateway = new WebSocketManager({ + this.#gateway = new WebSocketManager({ token: this.env.discordToken, rest: this.api.rest, intents: @@ -35,19 +35,21 @@ export class Gateway { GatewayIntentBits.MessageContent, }); - this.gateway + this.#gateway .on(WebSocketShardEvents.Debug, ({ message, shardId }) => this.logger.debug({ shardId }, message)) .on(WebSocketShardEvents.Hello, ({ shardId }) => this.logger.debug({ shardId }, 'Shard HELLO')) .on(WebSocketShardEvents.Ready, ({ shardId }) => this.logger.debug({ shardId }, 'Shard READY')) .on(WebSocketShardEvents.Resumed, ({ shardId }) => this.logger.debug({ shardId }, 'Shard RESUMED')) - .on(WebSocketShardEvents.Dispatch, ({ data }) => void this.broker.publish(data.t, data.d)); + .on(WebSocketShardEvents.Dispatch, ({ data }) => void this.#broker.publish(data.t, data.d)); + + this.#broker.on('send', async ({ data, ack }) => { + this.logger.info({ data }, 'Sending payload'); - this.broker.on('send', async ({ data, ack }) => { if (data.shardId) { - await this.gateway.send(data.shardId, data.payload); + await this.#gateway.send(data.shardId, data.payload); } else { - for (const shardId of await this.gateway.getShardIds()) { - await this.gateway.send(shardId, data.payload); + for (const shardId of await this.#gateway.getShardIds()) { + await this.#gateway.send(shardId, data.payload); } } @@ -57,7 +59,7 @@ export class Gateway { public async connect(): Promise { // Want a random group name so we fan out gateway_send payloads - await this.broker.subscribe(randomBytes(16).toString('hex'), ['send']); - await this.gateway.connect(); + await this.#broker.subscribe(randomBytes(16).toString('hex'), ['send']); + await this.#gateway.connect(); } } diff --git a/services/gateway/src/index.ts b/services/gateway/src/index.ts index f4c0628a..5c9756f5 100644 --- a/services/gateway/src/index.ts +++ b/services/gateway/src/index.ts @@ -5,6 +5,6 @@ import { Gateway } from './gateway.js'; const dependencyManager = globalContainer.get(DependencyManager); dependencyManager.registerRedis(); dependencyManager.registerApi(); -dependencyManager.registerLogger(); -await globalContainer.get(Gateway).connect(); +const gateway = globalContainer.get(Gateway); +await gateway.connect(); diff --git a/services/interactions/package.json b/services/interactions/package.json deleted file mode 100644 index c87ccef3..00000000 --- a/services/interactions/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@chatsift/automoderator-interactions", - "main": "./dist/index.js", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "lint": "eslint src --ext .ts", - "build": "tsc" - }, - "engines": { - "node": ">=16.9.0" - }, - "devDependencies": { - "@types/node": "^20.6.3", - "pino": "^8.15.1", - "typescript": "^5.2.2" - }, - "dependencies": { - "@automoderator/core": "workspace:^", - "@chatsift/readdir": "^0.3.0", - "@discordjs/brokers": "^0.2.2", - "@discordjs/core": "^1.0.1", - "@discordjs/rest": "^2.0.1", - "@discordjs/ws": "^1.0.1", - "@sapphire/discord-utilities": "^3.1.1", - "inversify": "^6.0.1", - "ioredis": "^5.3.2", - "reflect-metadata": "^0.1.13", - "tslib": "^2.6.2", - "undici": "^5.25.0" - } -} diff --git a/services/interactions/src/handlers/dev.ts b/services/interactions/src/handlers/dev.ts deleted file mode 100644 index 8304fd74..00000000 --- a/services/interactions/src/handlers/dev.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { API } from '@discordjs/core'; -import { injectable } from 'inversify'; -import { InteractionsService, type CommandHandler, type Handler } from '../interactions.js'; - -/** - * @remarks - * Special dev commands not handled in this service, as they're scoped to a guild. - */ -@injectable() -export default class Dev implements Handler { - public constructor( - private readonly interactions: InteractionsService, - private readonly api: API, - ) {} - - public register() { - this.interactions.register({ - commands: [['deploy:none:none', this.handleDeploy]], - }); - } - - private readonly handleDeploy: CommandHandler = async (interaction) => { - await this.interactions.deployCommands(); - await this.api.interactions.reply(interaction.id, interaction.token, { content: 'Successfully deployed commands' }); - }; -} diff --git a/services/interactions/src/handlers/punishments.ts b/services/interactions/src/handlers/punishments.ts deleted file mode 100644 index 0a5eade7..00000000 --- a/services/interactions/src/handlers/punishments.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - INJECTION_TOKENS, - UserActionValidatorFactory, - parseRelativeTime, - type IRestrictModAction, - type RestrictCaseCreateData, -} from '@automoderator/core'; -import { - API, - ApplicationCommandOptionType, - ApplicationCommandType, - MessageFlags, - PermissionFlagsBits, -} from '@discordjs/core'; -import { inject, injectable } from 'inversify'; -import { InteractionsService, type CommandHandler, type Handler } from '../interactions.js'; - -@injectable() -export default class Punishments implements Handler { - public constructor( - private readonly interactions: InteractionsService, - private readonly api: API, - private readonly userActionValidatorFactory: UserActionValidatorFactory, - @inject(INJECTION_TOKENS.actions.restrict) private readonly action: IRestrictModAction, - ) {} - - public register() { - this.interactions.register({ - interactions: [ - { - name: 'restrict', - description: 'Restrict a user (assign a special, manually configured role to them)', - type: ApplicationCommandType.ChatInput, - dm_permission: false, - default_member_permissions: String(PermissionFlagsBits.ModerateMembers), - options: [ - { - name: 'target', - description: 'The user to restrict', - type: ApplicationCommandOptionType.User, - required: true, - }, - { - name: 'role', - description: 'The role to assign to the user', - type: ApplicationCommandOptionType.Role, - required: true, - }, - { - name: 'reason', - description: 'The reason for restricting the user', - type: ApplicationCommandOptionType.String, - required: false, - }, - { - name: 'clean', - description: - "Whether or not to remove the other user's roles for the duration of this action - defaults to true", - type: ApplicationCommandOptionType.Boolean, - required: false, - }, - { - name: 'duration', - description: 'How long this action should last for', - type: ApplicationCommandOptionType.String, - required: false, - autocomplete: true, - }, - ], - }, - ], - commands: [['restrict:none:none', this.handleRestrict]], - }); - } - - private readonly handleRestrict: CommandHandler = async (interaction, options) => { - const resolvedMember = options.getMember('target', true); - const target = { ...resolvedMember, user: options.getUser('target', true) }; - - const actionValidator = this.userActionValidatorFactory.build({ - guild: interaction.guild_id!, - moderator: interaction.member!, - target, - }); - - const validationResult = await actionValidator.targetIsActionable(); - if (!validationResult.ok) { - return this.api.interactions.reply(interaction.id, interaction.token, { - content: validationResult.reason, - flags: MessageFlags.Ephemeral, - }); - } - - let duration = null; - const durationStr = options.getString('duration'); - if (durationStr) { - const parsed = parseRelativeTime(durationStr); - if (!parsed.ok) { - return this.api.interactions.reply(interaction.id, interaction.token, { - content: `Failed to parse provided duration: ${parsed.error}`, - flags: MessageFlags.Ephemeral, - }); - } - - duration = parsed.value; - } - - const data: RestrictCaseCreateData = { - guildId: interaction.guild_id!, - modId: interaction.member!.user.id, - reason: options.getString('reason'), - targetId: target.user.id, - clean: options.getBoolean('clean') ?? true, - roleId: options.getRole('role', true).id, - expiresAt: duration ? new Date(Date.now() + duration) : null, - }; - - const cs = await this.action.execute(data); - await this.action.notify(data); - - // TODO: Logging - await this.api.interactions.reply(interaction.id, interaction.token, { - content: 'Successfully restricted user.', - flags: MessageFlags.Ephemeral, - }); - }; -} diff --git a/services/interactions/src/index.ts b/services/interactions/src/index.ts deleted file mode 100644 index bd6818af..00000000 --- a/services/interactions/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// TODO: Look into exposed ports - -import 'reflect-metadata'; -import { globalContainer, DependencyManager } from '@automoderator/core'; -import { InteractionsService } from './interactions.js'; - -const dependencyManager = globalContainer.get(DependencyManager); -dependencyManager.registerRedis(); -dependencyManager.registerApi(); -dependencyManager.registerLogger(); -dependencyManager.registerDatabase(); - -await globalContainer.get(InteractionsService).start(); diff --git a/services/interactions/src/interactions.ts b/services/interactions/src/interactions.ts deleted file mode 100644 index 47790345..00000000 --- a/services/interactions/src/interactions.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { Env, INJECTION_TOKENS, type DiscordEventsMap, encode, decode, globalContainer } from '@automoderator/core'; -import { readdirRecurse } from '@chatsift/readdir'; -import { PubSubRedisBroker } from '@discordjs/brokers'; -import { - API, - GatewayDispatchEvents, - InteractionType, - type APIApplicationCommandInteraction, - type RESTPostAPIApplicationCommandsJSONBody, - type APIMessageComponentInteraction, - type APIApplicationCommandAutocompleteInteraction, - type APIApplicationCommandInteractionDataIntegerOption, - type APIApplicationCommandInteractionDataNumberOption, - type APIApplicationCommandInteractionDataStringOption, - type APIModalSubmitInteraction, -} from '@discordjs/core'; -import { InteractionOptionResolver } from '@sapphire/discord-utilities'; -import { inject, injectable } from 'inversify'; -import { Redis } from 'ioredis'; -import { type Logger } from 'pino'; - -// commandName:subcommandGroup:subcommand -export type CommandIdentifier = `${string}:${string}:${string}`; - -export type CommandHandler = ( - interaction: APIApplicationCommandInteraction, - options: InteractionOptionResolver, -) => Promise; - -export type ComponentHandler = (interaction: APIMessageComponentInteraction, args: string[]) => Promise; - -// [command]:argName -export type AutocompleteIdentifier = `${CommandIdentifier}:${string}`; - -export type AutocompleteHandler = ( - interaction: APIApplicationCommandAutocompleteInteraction, - option: - | APIApplicationCommandInteractionDataIntegerOption - | APIApplicationCommandInteractionDataNumberOption - | APIApplicationCommandInteractionDataStringOption, -) => Promise; - -export type ModalHandler = (interaction: APIModalSubmitInteraction, args: string[]) => Promise; - -export interface RegisterOptions { - autocomplete?: [AutocompleteIdentifier, AutocompleteHandler][]; - commands?: [CommandIdentifier, CommandHandler][]; - components?: [string, ComponentHandler][]; - interactions?: RESTPostAPIApplicationCommandsJSONBody[]; - modals?: [string, ModalHandler][]; -} - -export interface Handler { - register(): void; -} - -export type HandlerConstructor = new (...args: unknown[]) => Handler; - -@injectable() -export class InteractionsService { - private readonly broker: PubSubRedisBroker; - - private readonly interactions: RESTPostAPIApplicationCommandsJSONBody[] = []; - - private readonly handlers = { - commands: new Map(), - components: new Map(), - autocomplete: new Map(), - modals: new Map(), - } as const; - - public constructor( - private readonly env: Env, - private readonly api: API, - @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, - @inject(INJECTION_TOKENS.redis) private readonly redis: Redis, - ) { - this.broker = new PubSubRedisBroker({ - redisClient: this.redis, - encode, - decode, - }); - - this.broker.on(GatewayDispatchEvents.InteractionCreate, async ({ data: interaction, ack }) => { - try { - if (interaction.type === InteractionType.ApplicationCommand) { - // @ts-expect-error - version miss match - const options = new InteractionOptionResolver(interaction); - - const [identifier, fallbackIdentifier] = this.getCommandIdentifier(interaction, options); - const handler = this.handlers.commands.get(identifier); - - if (handler) { - await handler(interaction, options); - return; - } - - // Some commands might have a single handler for all subcommands - if (fallbackIdentifier) { - const fallback = this.handlers.commands.get(fallbackIdentifier); - await fallback?.(interaction, options); - } - - this.logger.warn(`No handler found for command ${identifier}`); - } else if (interaction.type === InteractionType.MessageComponent) { - const [name, ...args] = interaction.data.custom_id.split(':') as [string, ...string[]]; - const handler = this.handlers.components.get(name); - - if (!handler) { - this.logger.warn(`No handler found for component ${name}`); - } - - await handler?.(interaction, args); - } else if (interaction.type === InteractionType.ApplicationCommandAutocomplete) { - // @ts-expect-error - version miss match - const options = new InteractionOptionResolver(interaction); - const focused = options.getFocusedOption(); - - const [identifier, fallbackIdentifier] = this.getCommandIdentifier(interaction, options); - const handler = this.handlers.autocomplete.get(`${identifier}:${focused.name}`); - - if (handler) { - await handler(interaction, focused); - return; - } - - if (fallbackIdentifier) { - const fallback = this.handlers.autocomplete.get(`${fallbackIdentifier}:${focused.name}`); - await fallback?.(interaction, focused); - } - - this.logger.warn(`No handler found for autocomplete ${identifier}:${focused.name}`); - } else if (interaction.type === InteractionType.ModalSubmit) { - const [name, ...args] = interaction.data.custom_id.split(':') as [string, ...string[]]; - const handler = this.handlers.modals.get(name); - - if (!handler) { - this.logger.warn(`No handler found for modal ${name}`); - } - - await handler?.(interaction, args); - } - } catch (error) { - // TODO: Figure out ways to respond to the user - this.logger.error(error); - } finally { - await ack(); - } - }); - } - - public async start(): Promise { - const handlersPath = join(dirname(fileURLToPath(import.meta.url)), 'handlers'); - for await (const file of readdirRecurse(handlersPath, { fileExtensions: ['js'] })) { - const { default: HandlerConstructor }: { default: HandlerConstructor } = await import( - pathToFileURL(file).toString() - ); - const handler = globalContainer.get(HandlerConstructor); - handler.register(); - } - - await this.broker.subscribe('interactions', [GatewayDispatchEvents.InteractionCreate]); - this.logger.info('Subscribed to interactions'); - } - - public async deployCommands(): Promise { - await this.api.applicationCommands.bulkOverwriteGlobalCommands(this.env.discordClientId, this.interactions); - } - - public register(options: RegisterOptions): void { - if (options.interactions) { - this.interactions.push(...options.interactions); - } - - if (options.commands?.length) { - for (const [identifier, handler] of options.commands) { - this.handlers.commands.set(identifier, handler); - } - } - - if (options.components?.length) { - for (const [name, handler] of options.components) { - this.handlers.components.set(name, handler); - } - } - - if (options.autocomplete?.length) { - for (const [identifier, handler] of options.autocomplete) { - this.handlers.autocomplete.set(identifier, handler); - } - } - - if (options.modals?.length) { - for (const [name, handler] of options.modals) { - this.handlers.modals.set(name, handler); - } - } - } - - private getCommandIdentifier( - interaction: APIApplicationCommandAutocompleteInteraction | APIApplicationCommandInteraction, - options: InteractionOptionResolver, - ): [primary: CommandIdentifier, fallback?: CommandIdentifier] { - const group = options.getSubcommandGroup(false); - const subcommand = options.getSubcommand(false); - - const identifier: CommandIdentifier = `${interaction.data.name}:${group ?? 'none'}:${subcommand ?? 'none'}`; - if (subcommand) { - return [identifier, `${interaction.data.name}:none:none`]; - } - - return [identifier]; - } -} diff --git a/services/interactions/tsconfig.eslint.json b/services/interactions/tsconfig.eslint.json deleted file mode 100644 index e911c374..00000000 --- a/services/interactions/tsconfig.eslint.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "allowJs": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.cjs", - "**/*.jsx", - "**/*.test.ts", - "**/*.test.js", - "**/*.spec.ts", - "**/*.spec.js" - ], - "exclude": [] -} diff --git a/services/interactions/tsconfig.json b/services/interactions/tsconfig.json deleted file mode 100644 index f07d53cc..00000000 --- a/services/interactions/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src/**/*.ts"], - "exclude": ["./**/__tests__"] -} diff --git a/services/logging/package.json b/services/logging/package.json deleted file mode 100644 index c1e6d12d..00000000 --- a/services/logging/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@chatsift/automoderator-logging", - "main": "./dist/index.js", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "lint": "eslint src --ext .ts", - "build": "tsc" - }, - "engines": { - "node": ">=16.9.0" - }, - "devDependencies": { - "@types/node": "^20.6.3", - "pino": "^8.15.1", - "typescript": "^5.2.2" - }, - "dependencies": { - "@automoderator/core": "workspace:^", - "@discordjs/brokers": "^0.2.2", - "@discordjs/core": "^1.0.1", - "@discordjs/rest": "^2.0.1", - "@discordjs/ws": "^1.0.1", - "inversify": "^6.0.1", - "ioredis": "^5.3.2", - "kysely": "^0.26.3", - "reflect-metadata": "^0.1.13", - "tslib": "^2.6.2" - } -} diff --git a/services/logging/src/GuildLogger.ts b/services/logging/src/GuildLogger.ts deleted file mode 100644 index 5768417a..00000000 --- a/services/logging/src/GuildLogger.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { clearTimeout, setTimeout } from 'node:timers'; -import { URLSearchParams } from 'node:url'; -import type { DB, LogChannelType, LogChannelWebhook } from '@automoderator/core'; -import { API } from '@discordjs/core'; -import type { APIEmbed } from '@discordjs/core'; -import { DiscordAPIError } from '@discordjs/rest'; -import { inject, injectable } from 'inversify'; -import { Kysely } from 'kysely'; - -interface LogBuffer { - acks: (() => Promise)[]; - embeds: APIEmbed[]; - timeout: NodeJS.Timeout; -} - -interface LogData { - ack(): Promise; - embed: APIEmbed; - guildId: string; - logType: LogChannelType; -} - -@injectable() -export class GuildLogger { - private readonly buffers: Map<`${string}-${LogChannelType}`, LogBuffer>; - - public constructor( - private readonly api: API, - @inject(Kysely) private readonly database: Kysely, - ) { - this.buffers = new Map(); - } - - private async getWebhook(data: LogChannelWebhook) { - try { - const webhook = await this.api.webhooks.get(data.webhookId, { token: data.webhookToken }); - - return { - ...webhook, - threadId: data.threadId, - }; - } catch (error) { - if (error instanceof DiscordAPIError && error.status === 404) { - await this.database - .deleteFrom('LogChannelWebhook') - .where('guildId', '=', data.guildId) - .where('logType', '=', data.logType) - .execute(); - } - - return null; - } - } - - private async flushBuffer(guildId: string, logType: LogChannelType, buffer: LogBuffer) { - clearTimeout(buffer.timeout); - this.buffers.delete(`${guildId}-${logType}`); - - const webhookData = await this.database - .selectFrom('LogChannelWebhook') - .selectAll() - .where('guildId', '=', guildId) - .where('logType', '=', logType) - .executeTakeFirst(); - - if (!webhookData) { - return null; - } - - const webhook = await this.getWebhook(webhookData); - if (!webhook) { - return null; - } - - const query = new URLSearchParams({ wait: 'true' }); - if (webhook.threadId) { - query.append('thread_id', webhook.threadId); - } - - await this.api.webhooks.execute(webhook.id, webhook.token!, { wait: true, embeds: buffer.embeds }); - await Promise.all(buffer.acks.map(async (ack) => ack())); - } - - private assertBuffer(guildId: string, logType: LogChannelType): LogBuffer { - const key = `${guildId}-${logType}` as const; - const existing = this.buffers.get(key); - if (existing) { - return existing; - } - - const buffer: LogBuffer = { - embeds: [], - acks: [], - timeout: setTimeout(() => void this.flushBuffer(guildId, logType, buffer), 3_000).unref(), - }; - - this.buffers.set(key, buffer); - return buffer; - } - - public async log({ guildId, logType, embed, ack }: LogData) { - const buffer = this.assertBuffer(guildId, logType); - buffer.embeds.push(embed); - buffer.acks.push(ack); - - if (buffer.embeds.length === 10) { - await this.flushBuffer(guildId, logType, buffer); - } - } -} diff --git a/services/logging/src/Service.ts b/services/logging/src/Service.ts deleted file mode 100644 index 18038bde..00000000 --- a/services/logging/src/Service.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - INJECTION_TOKENS, - type GuildLogMap, - encode, - decode, - GuildLogType, - LogEmbedBuilder, - type DB, - LogChannelType, - promiseAllObject, - CaseAction, -} from '@automoderator/core'; -import { PubSubRedisBroker } from '@discordjs/brokers'; -import { API } from '@discordjs/core'; -import { inject, injectable } from 'inversify'; -import { Redis } from 'ioredis'; -import { Kysely } from 'kysely'; -import { type Logger } from 'pino'; -import { GuildLogger } from './GuildLogger.js'; - -@injectable() -export class LoggingService { - private readonly broker: PubSubRedisBroker; - - public constructor( - @inject(INJECTION_TOKENS.redis) private readonly redis: Redis, - private readonly guildLogger: GuildLogger, - private readonly api: API, - @inject(Kysely) private readonly database: Kysely, - private readonly embedBuilder: LogEmbedBuilder, - @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, - ) { - this.broker = new PubSubRedisBroker({ redisClient: this.redis, encode, decode }); - - this.broker.on(GuildLogType.ModAction, async ({ data: { cases }, ack }) => { - for (const cs of cases) { - const warnData = - cs.actionType === CaseAction.warn - ? await this.database.selectFrom('WarnCaseData').select('pardonedById').executeTakeFirst() - : null; - - const apiData = await promiseAllObject({ - mod: cs.modId ? this.api.users.get(cs.modId) : Promise.resolve(null), - user: cs.targetId ? this.api.users.get(cs.targetId) : Promise.resolve(null), - existingEmbed: - cs.logChannelId && cs.logMessageId - ? this.api.channels.getMessage(cs.logChannelId, cs.logMessageId).then((message) => message.embeds[0]) - : Promise.resolve(null), - pardonedBy: warnData?.pardonedById ? this.api.users.get(warnData?.pardonedById) : Promise.resolve(null), - }); - - const refCases = await this.database - .selectFrom('CaseReference') - .innerJoin('Case', 'Case.id', 'CaseReference.refId') - .selectAll() - .execute(); - - const referencedBy = await this.database - .selectFrom('CaseReference') - .innerJoin('Case', 'Case.id', 'CaseReference.caseId') - .selectAll() - .execute(); - - const embed = this.embedBuilder.buildModActionLog({ - cs, - ...apiData, - refCases, - referencedBy, - }); - - await this.guildLogger.log({ guildId: cs.guildId, logType: LogChannelType.mod, embed, ack }); - } - }); - } - - public async start(): Promise { - await this.broker.subscribe('logging', Object.values(GuildLogType)); - this.logger.info('Subscribed to logging events'); - } -} diff --git a/services/logging/src/index.ts b/services/logging/src/index.ts deleted file mode 100644 index ab3d44e4..00000000 --- a/services/logging/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import 'reflect-metadata'; -import { globalContainer, DependencyManager } from '@automoderator/core'; -import { LoggingService } from './Service.js'; - -const dependencyManager = globalContainer.get(DependencyManager); -dependencyManager.registerRedis(); -dependencyManager.registerApi(); -dependencyManager.registerLogger(); -dependencyManager.registerDatabase(); - -await globalContainer.get(LoggingService).start(); diff --git a/services/logging/tsconfig.eslint.json b/services/logging/tsconfig.eslint.json deleted file mode 100644 index e911c374..00000000 --- a/services/logging/tsconfig.eslint.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "allowJs": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.cjs", - "**/*.jsx", - "**/*.test.ts", - "**/*.test.js", - "**/*.spec.ts", - "**/*.spec.js" - ], - "exclude": [] -} diff --git a/services/logging/tsconfig.json b/services/logging/tsconfig.json deleted file mode 100644 index f07d53cc..00000000 --- a/services/logging/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src/**/*.ts"], - "exclude": ["./**/__tests__"] -} diff --git a/services/task-runner/package.json b/services/task-runner/package.json deleted file mode 100644 index da54e2ff..00000000 --- a/services/task-runner/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@chatsift/automoderator-task-runner", - "main": "./dist/index.js", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "lint": "eslint src --ext .ts", - "build": "tsc" - }, - "engines": { - "node": ">=16.9.0" - }, - "devDependencies": { - "@types/node": "^20.6.3", - "pino": "^8.15.1", - "typescript": "^5.2.2" - }, - "dependencies": { - "@automoderator/core": "workspace:^", - "inversify": "^6.0.1", - "ioredis": "^5.3.2", - "kysely": "^0.26.3", - "reflect-metadata": "^0.1.13", - "tslib": "^2.6.2" - } -} diff --git a/services/task-runner/src/index.ts b/services/task-runner/src/index.ts deleted file mode 100644 index 7a53eb25..00000000 --- a/services/task-runner/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import 'reflect-metadata'; -import { globalContainer, DependencyManager } from '@automoderator/core'; -import { TaskRunnerService } from './service.js'; - -const dependencyManager = globalContainer.get(DependencyManager); -dependencyManager.registerLogger(); -dependencyManager.registerDatabase(); - -await globalContainer.get(TaskRunnerService).start(); diff --git a/services/task-runner/src/service.ts b/services/task-runner/src/service.ts deleted file mode 100644 index e63855c9..00000000 --- a/services/task-runner/src/service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { setInterval } from 'node:timers'; -import { INJECTION_TOKENS, type DB, type Task, Env } from '@automoderator/core'; -import { inject, injectable } from 'inversify'; -import { Kysely, sql, type Selectable } from 'kysely'; -import { type Logger } from 'pino'; - -@injectable() -export class TaskRunnerService { - public constructor( - @inject(Kysely) private readonly database: Kysely, - @inject(INJECTION_TOKENS.logger) private readonly logger: Logger, - private readonly env: Env, - ) {} - - public async start(): Promise { - setInterval(async () => { - if (this.env.taskRunnerId === null) { - this.logger.warn('This process does not have a task runner ID, cannot operate.'); - return; - } - - const tasks = await this.database - .selectFrom('Task') - .selectAll() - .where(sql`mod("id", ${this.env.taskRunnerConcurrency}) = ${this.env.taskRunnerId}`) - .execute(); - - for (const task of tasks) { - try { - await this.handle(task); - await this.database.deleteFrom('Task').where('id', '=', task.id).execute(); - } catch (error) { - this.logger.error({ err: error, task }, 'Failed to handle task'); - const attempts = task.attempts + 1; - if (attempts >= 3) { - this.logger.warn(task, 'That was the 3rd attempt for that task; deleting'); - await this.database.deleteFrom('Task').where('id', '=', task.id).execute(); - } else { - await this.database.updateTable('Task').set({ attempts }).where('id', '=', task.id).execute(); - } - } - } - }); - - this.logger.info('Listening to incoming tasks'); - } - - // TODO: Factory pattern - private async handle(task: Selectable): Promise { - switch (task.type) { - default: { - this.logger.warn(task, `Unimplemented task type: ${task.type}`); - break; - } - } - } -} diff --git a/services/task-runner/tsconfig.eslint.json b/services/task-runner/tsconfig.eslint.json deleted file mode 100644 index e911c374..00000000 --- a/services/task-runner/tsconfig.eslint.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "allowJs": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.cjs", - "**/*.jsx", - "**/*.test.ts", - "**/*.test.js", - "**/*.spec.ts", - "**/*.spec.js" - ], - "exclude": [] -} diff --git a/services/task-runner/tsconfig.json b/services/task-runner/tsconfig.json deleted file mode 100644 index f07d53cc..00000000 --- a/services/task-runner/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src/**/*.ts"], - "exclude": ["./**/__tests__"] -} diff --git a/yarn.lock b/yarn.lock index 9b5857f4..b0122452 100644 --- a/yarn.lock +++ b/yarn.lock @@ -105,6 +105,7 @@ __metadata: inversify: ^6.0.1 ioredis: ^5.3.2 kysely: ^0.26.3 + murmurhash: ^2.0.1 pg: ^8.11.3 pino: ^8.15.1 pino-pretty: ^10.2.0 @@ -324,64 +325,6 @@ __metadata: languageName: unknown linkType: soft -"@chatsift/automoderator-interactions@workspace:services/interactions": - version: 0.0.0-use.local - resolution: "@chatsift/automoderator-interactions@workspace:services/interactions" - dependencies: - "@automoderator/core": "workspace:^" - "@chatsift/readdir": ^0.3.0 - "@discordjs/brokers": ^0.2.2 - "@discordjs/core": ^1.0.1 - "@discordjs/rest": ^2.0.1 - "@discordjs/ws": ^1.0.1 - "@sapphire/discord-utilities": ^3.1.1 - "@types/node": ^20.6.3 - inversify: ^6.0.1 - ioredis: ^5.3.2 - pino: ^8.15.1 - reflect-metadata: ^0.1.13 - tslib: ^2.6.2 - typescript: ^5.2.2 - undici: ^5.25.0 - languageName: unknown - linkType: soft - -"@chatsift/automoderator-logging@workspace:services/logging": - version: 0.0.0-use.local - resolution: "@chatsift/automoderator-logging@workspace:services/logging" - dependencies: - "@automoderator/core": "workspace:^" - "@discordjs/brokers": ^0.2.2 - "@discordjs/core": ^1.0.1 - "@discordjs/rest": ^2.0.1 - "@discordjs/ws": ^1.0.1 - "@types/node": ^20.6.3 - inversify: ^6.0.1 - ioredis: ^5.3.2 - kysely: ^0.26.3 - pino: ^8.15.1 - reflect-metadata: ^0.1.13 - tslib: ^2.6.2 - typescript: ^5.2.2 - languageName: unknown - linkType: soft - -"@chatsift/automoderator-task-runner@workspace:services/task-runner": - version: 0.0.0-use.local - resolution: "@chatsift/automoderator-task-runner@workspace:services/task-runner" - dependencies: - "@automoderator/core": "workspace:^" - "@types/node": ^20.6.3 - inversify: ^6.0.1 - ioredis: ^5.3.2 - kysely: ^0.26.3 - pino: ^8.15.1 - reflect-metadata: ^0.1.13 - tslib: ^2.6.2 - typescript: ^5.2.2 - languageName: unknown - linkType: soft - "@chatsift/automoderator@workspace:.": version: 0.0.0-use.local resolution: "@chatsift/automoderator@workspace:." @@ -414,16 +357,6 @@ __metadata: languageName: node linkType: hard -"@chatsift/readdir@npm:^0.3.0": - version: 0.3.0 - resolution: "@chatsift/readdir@npm:0.3.0" - dependencies: - tiny-typed-emitter: ^2.1.0 - tslib: ^2.5.0 - checksum: 6055f47dbc4e1aebaea3778d082cdd06fd098ed318e9d56584f87f47dda49f66e32c44bd06b6d70485f17f1f0bb6d74d7b02b480c6e48030af971b03f748d4b2 - languageName: node - linkType: hard - "@chevrotain/cst-dts-gen@npm:10.5.0": version: 10.5.0 resolution: "@chevrotain/cst-dts-gen@npm:10.5.0" @@ -1299,15 +1232,6 @@ __metadata: languageName: node linkType: hard -"@sapphire/discord-utilities@npm:^3.1.1": - version: 3.1.1 - resolution: "@sapphire/discord-utilities@npm:3.1.1" - dependencies: - discord-api-types: ^0.37.55 - checksum: 423c9cd541d2dbdbcaa18d7793ae40d557808c348d7fea1c6963b249bb647df3b99ac3fb24024820c8e84ae4300560d927cd7a3cefbf22fa0f0dfd49f5764ee8 - languageName: node - linkType: hard - "@sapphire/shapeshift@npm:^3.9.2": version: 3.9.2 resolution: "@sapphire/shapeshift@npm:3.9.2" @@ -3153,13 +3077,6 @@ __metadata: languageName: node linkType: hard -"discord-api-types@npm:^0.37.55": - version: 0.37.56 - resolution: "discord-api-types@npm:0.37.56" - checksum: 797be690af70a846422f955d918a5713a2c8c37bea646e2120996522afbb47fc5893122c1ddcb8f57a285ace6e1fb0237d1e63105444ae52534f0570a2f87f34 - languageName: node - linkType: hard - "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -6716,6 +6633,13 @@ __metadata: languageName: node linkType: hard +"murmurhash@npm:^2.0.1": + version: 2.0.1 + resolution: "murmurhash@npm:2.0.1" + checksum: 8bb90f43b1c41b1d046efe662a02dcc3cdbda4b42fbbadf7488046772d9eecf2b37ffb6cb73d0d96f2ff489d9f4a8252c674bbae588a6179f60854fe0bfe3325 + languageName: node + linkType: hard + "nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" @@ -8862,13 +8786,6 @@ __metadata: languageName: node linkType: hard -"tiny-typed-emitter@npm:^2.1.0": - version: 2.1.0 - resolution: "tiny-typed-emitter@npm:2.1.0" - checksum: 709bca410054e08df4dc29d5ea0916328bb2900d60245c6a743068ea223887d9fd2c945b6070eb20336275a557a36c2808e5c87d2ed4b60633458632be4a3e10 - languageName: node - linkType: hard - "tmp@npm:0.2.1": version: 0.2.1 resolution: "tmp@npm:0.2.1"