diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..03aad50 --- /dev/null +++ b/claude.md @@ -0,0 +1,86 @@ +# Discord Bot + +Immich Discord bot built with NestJS, discordx, and PostgreSQL (Kysely ORM). + +## Tech Stack + +- **Runtime**: Node.js (24.x), TypeScript, CommonJS +- **Framework**: NestJS with `@nestjs/schedule` for cron jobs +- **Discord**: discord.js + discordx (decorator-based slash commands, events, modals, buttons) +- **Database**: PostgreSQL via Kysely (type-safe query builder), file-based migrations +- **Testing**: Vitest with manual mocks (no test database) +- **Build**: `nest build` (SWC compiler), `eslint`, `prettier` + +## Architecture + +### Layers + +1. **Discord layer** (`src/discord/`) - Slash commands, events, help-desk, context menus. These are `@Discord()` + `@Injectable()` NestJS classes that use discordx decorators (`@Slash`, `@On`, `@ModalComponent`, `@ButtonComponent`). +2. **Service layer** (`src/services/`) - Business logic. Injected into discord layer. Services use `@Inject(ITokenName)` for repository dependencies. +3. **Repository layer** (`src/repositories/`) - External integrations (database, Discord API, GitHub, Zulip, RSS, etc). Each has an interface in `src/interfaces/` with a string token (`export const IFoo = 'IFoo'`). +4. **Interface layer** (`src/interfaces/`) - Defines repository contracts and Kysely table types. The `Database` type in `database.interface.ts` maps table names to their column types. + +### Dependency Injection + +Repositories are provided via NestJS DI tokens in `src/repositories/index.ts`: +```ts +{ provide: IDatabaseRepository, useClass: DatabaseRepository } +``` +Services inject them with `@Inject(IDatabaseRepository)`. + +### Registration + +- **Services**: Listed in `src/services/index.ts` → imported into `AppModule` +- **Repositories/Providers**: Listed in `src/repositories/index.ts` → imported into `AppModule` +- **Discord classes**: Listed directly in `AppModule` (`DiscordCommands`, `DiscordEvents`, `DiscordHelpDesk`, `DiscordContextMenus`) + +### Database Migrations + +Located in `src/migrations/` with naming pattern `{timestamp}-{description}.ts`. Each exports `up()` and `down()` functions using Kysely schema builder. Migrations run automatically on module init via `DatabaseService.runMigrations()`. + +### Adding a New Database Table + +1. Create migration in `src/migrations/{timestamp}-{name}.ts` +2. Add table type + Selectable/Insertable/Updateable types in `src/interfaces/database.interface.ts` +3. Add table to the `Database` interface +4. Add repository methods to `IDatabaseRepository` interface +5. Implement methods in `src/repositories/database.repository.ts` + +### Adding a New Slash Command + +Commands live in `src/discord/commands.ts`. Use discordx decorators: +- `@Slash({ name, description })` on method +- `@SlashOption({...})` for parameters +- `@SlashChoice(...)` for enum choices +- Autocomplete: set `autocomplete: true` on option, check `interaction.isAutocomplete()` in handler + +### Auth Guard + +Legacy commands use `authGuard()` to restrict to allowed channels (BotSpam, SupportCrew, QQ). **New commands should NOT use `authGuard`** — permissions will be configured via Discord's built-in command permissions UI instead. + +### Cron Jobs + +Use `@Cron(expression)` decorator from `@nestjs/schedule`. Cron expressions stored in `Constants.Cron`. + +## Commands + +``` +npm run build # Build with nest +npm run check # TypeScript type check +npm run lint # ESLint +npm run format # Prettier check +npm run test # Vitest +npm run check:all # format + lint + check + test:cov +``` + +## Key Files + +- `src/app.module.ts` - Root NestJS module +- `src/main.ts` - Bootstrap, Discord client init +- `src/config.ts` - Environment variable loading +- `src/constants.ts` - Enums, channel IDs, role IDs, cron expressions +- `src/discord/commands.ts` - All slash commands +- `src/discord/events.ts` - Discord event handlers +- `src/services/discord.service.ts` - Core bot logic +- `src/interfaces/database.interface.ts` - DB schema types + repository interface +- `src/repositories/database.repository.ts` - Kysely DB queries diff --git a/src/discord/commands.ts b/src/discord/commands.ts index 08dc5cd..eaccbda 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -3,7 +3,11 @@ import { ActionRowBuilder, ApplicationCommandOptionType, AutocompleteInteraction, + bold, + Channel, + channelMention, GuildMember, + inlineCode, MessageFlags, ModalBuilder, ModalSubmitInteraction, @@ -17,6 +21,7 @@ import { Constants, DiscordField, DiscordModal } from 'src/constants'; import { DiscordChannel } from 'src/interfaces/discord.interface'; import { DiscordService } from 'src/services/discord.service'; import { RSSService } from 'src/services/rss.service'; +import { ScheduledMessageService } from 'src/services/scheduled-message.service'; const authGuard = async (interaction: CommandInteraction) => { const isValid = [ @@ -42,6 +47,7 @@ export class DiscordCommands { constructor( private service: DiscordService, private rssService: RSSService, + private scheduledMessageService: ScheduledMessageService, ) {} @Slash({ name: 'link-add', description: 'Add a new link' }) @@ -500,4 +506,99 @@ export class DiscordCommands { await deferredInteraction.edit(`Successfully cleaned up ${member.user.toString()}`); } + + @Slash({ name: 'schedule-add', description: 'Create a recurring scheduled message' }) + async handleScheduleAdd( + @SlashOption({ + name: 'name', + description: 'A unique name for this scheduled message', + type: ApplicationCommandOptionType.String, + required: true, + }) + name: string, + @SlashOption({ + name: 'cron', + description: 'Cron expression (e.g. "0 9 * * 1" for every Monday at 9am UTC)', + type: ApplicationCommandOptionType.String, + required: true, + }) + cronExpression: string, + @SlashOption({ + name: 'message', + description: 'The message to send (supports role pings like <@&roleId>)', + type: ApplicationCommandOptionType.String, + required: true, + }) + message: string, + @SlashOption({ + name: 'channel', + description: 'The channel to send to (defaults to current channel)', + type: ApplicationCommandOptionType.Channel, + required: false, + }) + channel: Channel | null, + interaction: CommandInteraction, + ) { + const channelId = channel?.id ?? interaction.channelId; + + try { + await this.scheduledMessageService.createScheduledMessage({ + name, + cronExpression, + message, + channelId, + createdBy: interaction.user.id, + }); + + return interaction.reply({ + content: `Scheduled message ${inlineCode(name)} created with cron ${inlineCode(cronExpression)} in ${channel}`, + flags: [MessageFlags.Ephemeral], + }); + } catch (error) { + return interaction.reply({ + content: `Failed to create scheduled message: ${error}`, + flags: [MessageFlags.Ephemeral], + }); + } + } + + @Slash({ name: 'schedule-remove', description: 'Remove a scheduled message' }) + async handleScheduleRemove( + @SlashOption({ + name: 'name', + description: 'The name of the scheduled message to remove', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + }) + name: string, + interaction: CommandInteraction | AutocompleteInteraction, + ) { + if (interaction.isAutocomplete()) { + const value = interaction.options.getFocused(true).value; + const results = await this.scheduledMessageService.getScheduledMessages(value); + return interaction.respond(results); + } + + const message = await this.scheduledMessageService.removeScheduledMessage(name); + return interaction.reply({ content: message }); + } + + @Slash({ name: 'schedule-list', description: 'List all scheduled messages' }) + async handleScheduleList(interaction: CommandInteraction) { + const messages = await this.scheduledMessageService.listScheduledMessages(); + + if (messages.length === 0) { + return interaction.reply({ content: 'No scheduled messages found.', flags: [MessageFlags.Ephemeral] }); + } + + const content = messages + .map( + (message) => + `- ${bold(message.name)}: ${inlineCode(message.cronExpression)} in ${channelMention(message.channelId)}\n ${message.message}`, + ) + .join('\n'); + + return interaction.reply({ content, flags: [MessageFlags.Ephemeral] }); + } } diff --git a/src/interfaces/database.interface.ts b/src/interfaces/database.interface.ts index 89bb856..ad9536f 100644 --- a/src/interfaces/database.interface.ts +++ b/src/interfaces/database.interface.ts @@ -88,6 +88,19 @@ export type RSSFeed = Selectable; export type NewRSSFeed = Insertable; export type UpdateRSSFeed = Updateable & { url: string; channelId: string }; +export interface ScheduledMessagesTable { + id: Generated; + channelId: string; + message: string; + cronExpression: string; + createdBy: string; + name: string; + createdAt: Generated; +} + +export type ScheduledMessage = Selectable; +export type NewScheduledMessage = Insertable; + export interface Database { payment: PaymentTable; sponsor: SponsorTable; @@ -95,6 +108,7 @@ export interface Database { discord_messages: DiscordMessagesTable; fourthwall_orders: FourthwallOrdersTable; rss_feeds: RSSFeedsTable; + scheduled_messages: ScheduledMessagesTable; } export type ReportOptions = { @@ -125,4 +139,8 @@ export interface IDatabaseRepository { getRSSFeeds(channelId?: string): Promise; removeRSSFeed(url: string, channelId: string): Promise; updateRSSFeed(entity: UpdateRSSFeed): Promise; + getScheduledMessages(): Promise; + getScheduledMessage(name: string): Promise; + createScheduledMessage(entity: NewScheduledMessage): Promise; + removeScheduledMessage(id: string): Promise; } diff --git a/src/migrations/1745700000-createScheduledMessagesTable.ts b/src/migrations/1745700000-createScheduledMessagesTable.ts new file mode 100644 index 0000000..e20b5cd --- /dev/null +++ b/src/migrations/1745700000-createScheduledMessagesTable.ts @@ -0,0 +1,18 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('scheduled_messages') + .addColumn('id', 'uuid', (oc) => oc.primaryKey().defaultTo(db.fn('gen_random_uuid'))) + .addColumn('channelId', 'varchar', (oc) => oc.notNull()) + .addColumn('message', 'text', (oc) => oc.notNull()) + .addColumn('cronExpression', 'varchar', (oc) => oc.notNull()) + .addColumn('createdBy', 'varchar', (oc) => oc.notNull()) + .addColumn('name', 'varchar', (oc) => oc.notNull().unique()) + .addColumn('createdAt', 'timestamptz', (oc) => oc.notNull().defaultTo(db.fn('now'))) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('scheduled_messages').execute(); +} diff --git a/src/repositories/database.repository.ts b/src/repositories/database.repository.ts index 2753e48..0105b00 100644 --- a/src/repositories/database.repository.ts +++ b/src/repositories/database.repository.ts @@ -16,8 +16,10 @@ import { NewFourthwallOrder, NewPayment, NewRSSFeed, + NewScheduledMessage, ReportOptions, RSSFeed, + ScheduledMessage, UpdateDiscordMessage, UpdateFourthwallOrder, UpdateRSSFeed, @@ -209,4 +211,20 @@ export class DatabaseRepository implements IDatabaseRepository { .where('rss_feeds.channelId', '=', entity.channelId) .execute(); } + + getScheduledMessages(): Promise { + return this.db.selectFrom('scheduled_messages').selectAll().execute(); + } + + getScheduledMessage(name: string): Promise { + return this.db.selectFrom('scheduled_messages').where('name', '=', name).selectAll().executeTakeFirst(); + } + + createScheduledMessage(entity: NewScheduledMessage): Promise { + return this.db.insertInto('scheduled_messages').values(entity).returningAll().executeTakeFirstOrThrow(); + } + + async removeScheduledMessage(id: string): Promise { + await this.db.deleteFrom('scheduled_messages').where('id', '=', id).execute(); + } } diff --git a/src/services/discord.service.spec.ts b/src/services/discord.service.spec.ts index 0be882e..030d119 100644 --- a/src/services/discord.service.spec.ts +++ b/src/services/discord.service.spec.ts @@ -63,6 +63,10 @@ const newDatabaseMockRepository = (): Mocked => ({ getRSSFeeds: vitest.fn(), updateRSSFeed: vitest.fn(), removeRSSFeed: vitest.fn(), + getScheduledMessages: vitest.fn(), + getScheduledMessage: vitest.fn(), + createScheduledMessage: vitest.fn(), + removeScheduledMessage: vitest.fn(), }); const newFourthwallMockRepository = (): Mocked => ({ diff --git a/src/services/index.ts b/src/services/index.ts index 99afe5f..a766573 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -2,6 +2,7 @@ import { DatabaseService } from 'src/services/database.service'; import { DiscordService } from 'src/services/discord.service'; import { ReportService } from 'src/services/report.service'; import { RSSService } from 'src/services/rss.service'; +import { ScheduledMessageService } from 'src/services/scheduled-message.service'; import { WebhookService } from 'src/services/webhook.service'; import { ZulipService } from 'src/services/zulip.service'; @@ -11,6 +12,7 @@ export const services = [ DiscordService, ReportService, RSSService, + ScheduledMessageService, WebhookService, ZulipService, ]; diff --git a/src/services/scheduled-message.service.spec.ts b/src/services/scheduled-message.service.spec.ts new file mode 100644 index 0000000..65b44ab --- /dev/null +++ b/src/services/scheduled-message.service.spec.ts @@ -0,0 +1,173 @@ +import { IDatabaseRepository, ScheduledMessage } from 'src/interfaces/database.interface'; +import { IDiscordInterface } from 'src/interfaces/discord.interface'; +import { ScheduledMessageService } from 'src/services/scheduled-message.service'; +import { Mocked, beforeEach, describe, expect, it, vitest } from 'vitest'; + +const newDatabaseMock = (): Mocked< + Pick< + IDatabaseRepository, + 'getScheduledMessages' | 'getScheduledMessage' | 'createScheduledMessage' | 'removeScheduledMessage' + > +> => ({ + getScheduledMessages: vitest.fn().mockResolvedValue([]), + getScheduledMessage: vitest.fn(), + createScheduledMessage: vitest.fn(), + removeScheduledMessage: vitest.fn(), +}); + +const newDiscordMock = (): Mocked => ({ + login: vitest.fn(), + sendMessage: vitest.fn(), + createEmote: vitest.fn(), + getEmotes: vitest.fn(), +}); + +const makeScheduledMessage = (overrides: Partial = {}): ScheduledMessage => ({ + id: 'msg-1', + name: 'test-message', + channelId: '123456', + message: 'Hello world', + cronExpression: '0 9 * * 1', + createdBy: 'user-1', + createdAt: new Date(), + ...overrides, +}); + +describe('ScheduledMessageService', () => { + let sut: ScheduledMessageService; + let databaseMock: ReturnType; + let discordMock: Mocked; + + beforeEach(() => { + databaseMock = newDatabaseMock(); + discordMock = newDiscordMock(); + sut = new ScheduledMessageService(databaseMock as unknown as IDatabaseRepository, discordMock); + }); + + describe('onModuleInit', () => { + it('should load and register all scheduled messages from the database', async () => { + const messages = [ + makeScheduledMessage({ id: '1', name: 'msg-a', cronExpression: '0 9 * * 1' }), + makeScheduledMessage({ id: '2', name: 'msg-b', cronExpression: '0 12 * * *' }), + ]; + databaseMock.getScheduledMessages.mockResolvedValue(messages); + + await sut.onModuleInit(); + + expect(databaseMock.getScheduledMessages).toHaveBeenCalledOnce(); + }); + }); + + describe('createScheduledMessage', () => { + it('should reject invalid cron expressions before persisting', async () => { + const entity = { + name: 'bad-cron', + channelId: '123', + message: 'test', + cronExpression: 'not a cron', + createdBy: 'user-1', + }; + + await expect(sut.createScheduledMessage(entity)).rejects.toThrow(); + expect(databaseMock.createScheduledMessage).not.toHaveBeenCalled(); + }); + + it('should persist and register a job for a valid cron expression', async () => { + const entity = { + name: 'valid-message', + channelId: '123', + message: 'Hello!', + cronExpression: '0 9 * * 1', + createdBy: 'user-1', + }; + const created = makeScheduledMessage({ id: 'new-1', ...entity }); + databaseMock.createScheduledMessage.mockResolvedValue(created); + + await sut.createScheduledMessage(entity); + + expect(databaseMock.createScheduledMessage).toHaveBeenCalledWith(entity); + }); + }); + + describe('removeScheduledMessage', () => { + it('should return not-found message when name does not exist', async () => { + databaseMock.getScheduledMessage.mockResolvedValue(undefined); + + const result = await sut.removeScheduledMessage('nonexistent'); + + expect(result).toEqual('Scheduled message not found'); + expect(databaseMock.removeScheduledMessage).not.toHaveBeenCalled(); + }); + + it('should stop the job, remove from DB, and return success', async () => { + const msg = makeScheduledMessage({ id: 'rm-1', name: 'to-remove' }); + databaseMock.getScheduledMessage.mockResolvedValue(msg); + + const result = await sut.removeScheduledMessage('to-remove'); + + expect(databaseMock.removeScheduledMessage).toHaveBeenCalledWith('rm-1'); + expect(result).toEqual('Removed scheduled message `to-remove`'); + }); + }); + + describe('getScheduledMessages (autocomplete)', () => { + it('should return all messages formatted for autocomplete', async () => { + databaseMock.getScheduledMessages.mockResolvedValue([ + makeScheduledMessage({ name: 'daily-standup', cronExpression: '0 9 * * *', message: 'Time for standup!' }), + makeScheduledMessage({ name: 'weekly-recap', cronExpression: '0 17 * * 5', message: 'Weekly recap time' }), + ]); + + const result = await sut.getScheduledMessages(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: expect.stringContaining('daily-standup'), + value: 'daily-standup', + }); + expect(result[1]).toEqual({ + name: expect.stringContaining('weekly-recap'), + value: 'weekly-recap', + }); + }); + + it('should filter by name when a search value is provided', async () => { + databaseMock.getScheduledMessages.mockResolvedValue([ + makeScheduledMessage({ name: 'daily-standup' }), + makeScheduledMessage({ name: 'weekly-recap' }), + ]); + + const result = await sut.getScheduledMessages('daily'); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('daily-standup'); + }); + + it('should filter case-insensitively', async () => { + databaseMock.getScheduledMessages.mockResolvedValue([makeScheduledMessage({ name: 'Daily-Standup' })]); + + const result = await sut.getScheduledMessages('daily'); + + expect(result).toHaveLength(1); + }); + + it('should limit results to 25 entries', async () => { + const messages = Array.from({ length: 30 }, (_, i) => makeScheduledMessage({ name: `msg-${i}` })); + databaseMock.getScheduledMessages.mockResolvedValue(messages); + + const result = await sut.getScheduledMessages(); + + expect(result).toHaveLength(25); + }); + }); + + describe('listScheduledMessages', () => { + it('should return all messages when no channel filter is provided', async () => { + const messages = [makeScheduledMessage({ channelId: 'ch-1' }), makeScheduledMessage({ channelId: 'ch-2' })]; + databaseMock.getScheduledMessages.mockResolvedValue(messages); + + const result = await sut.listScheduledMessages(); + + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/src/services/scheduled-message.service.ts b/src/services/scheduled-message.service.ts new file mode 100644 index 0000000..9573418 --- /dev/null +++ b/src/services/scheduled-message.service.ts @@ -0,0 +1,95 @@ +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { CronJob } from 'cron'; +import { inlineCode } from 'discord.js'; +import { IDatabaseRepository, NewScheduledMessage } from 'src/interfaces/database.interface'; +import { IDiscordInterface } from 'src/interfaces/discord.interface'; +import { shorten } from 'src/util'; + +@Injectable() +export class ScheduledMessageService implements OnModuleInit { + private logger = new Logger(ScheduledMessageService.name); + private jobs = new Map(); + + constructor( + @Inject(IDatabaseRepository) private database: IDatabaseRepository, + @Inject(IDiscordInterface) private discord: IDiscordInterface, + ) {} + + async onModuleInit() { + await this.loadScheduledMessages(); + } + + private async loadScheduledMessages() { + const messages = await this.database.getScheduledMessages(); + for (const message of messages) { + this.registerJob(message); + } + } + + private registerJob({ + id, + cronExpression, + channelId, + message, + }: { + id: string; + cronExpression: string; + channelId: string; + message: string; + }) { + const job = CronJob.from({ + cronTime: cronExpression, + onTick: async () => { + try { + await this.discord.sendMessage({ channelId, message: { content: message } }); + } catch (error) { + this.logger.error(`Failed to send scheduled message ${id}: ${error}`); + } + }, + start: true, + }); + this.jobs.set(id, job); + } + + async createScheduledMessage(entity: NewScheduledMessage) { + new CronJob(entity.cronExpression, () => {}); + + const message = await this.database.createScheduledMessage(entity); + this.registerJob(message); + } + + async removeScheduledMessage(name: string) { + const message = await this.database.getScheduledMessage(name); + if (!message) { + return 'Scheduled message not found'; + } + + const job = this.jobs.get(message.id); + if (job) { + await job.stop(); + this.jobs.delete(message.id); + } + + await this.database.removeScheduledMessage(message.id); + return `Removed scheduled message ${inlineCode(message.name)}`; + } + + async getScheduledMessages(value?: string) { + let messages = await this.database.getScheduledMessages(); + if (value) { + const query = value.toLowerCase(); + messages = messages.filter(({ name }) => name.toLowerCase().includes(query)); + } + + return messages + .map(({ name, cronExpression, message }) => ({ + name: shorten(`${name} — ${cronExpression} — ${message}`, 100), + value: name, + })) + .slice(0, 25); + } + + async listScheduledMessages() { + return this.database.getScheduledMessages(); + } +}