mirror of
https://github.com/immich-app/discord-bot.git
synced 2026-09-30 13:32:52 +08:00
feat: add scheduled message functionality with cron job support (#452)
This commit is contained in:
@@ -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
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user