feat: add scheduled message functionality with cron job support (#452)

This commit is contained in:
Zack Pollard
2026-02-25 19:24:01 +01:00
committed by GitHub
parent 5ce1f56d00
commit 2fc02ff95e
9 changed files with 515 additions and 0 deletions
+86
View File
@@ -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
+101
View File
@@ -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] });
}
}
+18
View File
@@ -88,6 +88,19 @@ export type RSSFeed = Selectable<RSSFeedsTable>;
export type NewRSSFeed = Insertable<RSSFeedsTable>;
export type UpdateRSSFeed = Updateable<RSSFeedsTable> & { url: string; channelId: string };
export interface ScheduledMessagesTable {
id: Generated<string>;
channelId: string;
message: string;
cronExpression: string;
createdBy: string;
name: string;
createdAt: Generated<Date>;
}
export type ScheduledMessage = Selectable<ScheduledMessagesTable>;
export type NewScheduledMessage = Insertable<ScheduledMessagesTable>;
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<RSSFeed[]>;
removeRSSFeed(url: string, channelId: string): Promise<void>;
updateRSSFeed(entity: UpdateRSSFeed): Promise<void>;
getScheduledMessages(): Promise<ScheduledMessage[]>;
getScheduledMessage(name: string): Promise<ScheduledMessage | undefined>;
createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage>;
removeScheduledMessage(id: string): Promise<void>;
}
@@ -0,0 +1,18 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
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<unknown>): Promise<void> {
await db.schema.dropTable('scheduled_messages').execute();
}
+18
View File
@@ -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<ScheduledMessage[]> {
return this.db.selectFrom('scheduled_messages').selectAll().execute();
}
getScheduledMessage(name: string): Promise<ScheduledMessage | undefined> {
return this.db.selectFrom('scheduled_messages').where('name', '=', name).selectAll().executeTakeFirst();
}
createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage> {
return this.db.insertInto('scheduled_messages').values(entity).returningAll().executeTakeFirstOrThrow();
}
async removeScheduledMessage(id: string): Promise<void> {
await this.db.deleteFrom('scheduled_messages').where('id', '=', id).execute();
}
}
+4
View File
@@ -63,6 +63,10 @@ const newDatabaseMockRepository = (): Mocked<IDatabaseRepository> => ({
getRSSFeeds: vitest.fn(),
updateRSSFeed: vitest.fn(),
removeRSSFeed: vitest.fn(),
getScheduledMessages: vitest.fn(),
getScheduledMessage: vitest.fn(),
createScheduledMessage: vitest.fn(),
removeScheduledMessage: vitest.fn(),
});
const newFourthwallMockRepository = (): Mocked<IFourthwallRepository> => ({
+2
View File
@@ -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,
];
@@ -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<IDiscordInterface> => ({
login: vitest.fn(),
sendMessage: vitest.fn(),
createEmote: vitest.fn(),
getEmotes: vitest.fn(),
});
const makeScheduledMessage = (overrides: Partial<ScheduledMessage> = {}): 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<typeof newDatabaseMock>;
let discordMock: Mocked<IDiscordInterface>;
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);
});
});
});
+95
View File
@@ -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<string, CronJob>();
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();
}
}