From 733a2a0d72d633aed5f5708ac92e041c2ca8090d Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:22:05 +0200 Subject: [PATCH] feat: mattermost modals (#621) --- src/controllers/webhook.controller.ts | 8 +- src/discord/commands.ts | 4 +- src/interfaces/database.interface.ts | 4 +- src/interfaces/mattermost.interface.ts | 101 ++++++++++++++++++ src/repositories/database.repository.ts | 17 ++- src/repositories/mattermost.repository.ts | 37 +++++++ ...8800743598-ScheduledMessagesCompositePK.ts | 11 ++ src/schema/tables/scheduled-messages.table.ts | 6 +- src/services/chat.service.spec.ts | 2 + .../scheduled-message.service.spec.ts | 8 +- src/services/scheduled-message.service.ts | 86 +++++++++++++-- src/services/webhook.service.ts | 6 +- tsconfig.json | 8 +- 13 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 src/schema/migrations/1788800743598-ScheduledMessagesCompositePK.ts diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 23510e0..689f012 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -3,7 +3,7 @@ import type { EmitterWebhookEvent } from '@octokit/webhooks'; import { Request, Response } from 'express'; import { GithubStatusComponent, GithubStatusIncident, StripeBase } from 'src/dtos/webhook.dto'; import { FourthwallOrderCreateWebhook, FourthwallOrderUpdateWebhook } from 'src/interfaces/fourthwall.interface'; -import { CommandWebhookRequest } from 'src/interfaces/mattermost.interface'; +import { CommandWebhookRequest, DialogResponse } from 'src/interfaces/mattermost.interface'; import { WebhookService } from 'src/services/webhook.service'; @Injectable() @@ -54,4 +54,10 @@ export class WebhookController { async onMattermostCommand(@Body() dto: CommandWebhookRequest, @Param('slug') slug: string) { return this.service.onMattermostCommand(dto, slug); } + + @Post('mattermost/dialog/:slug') + @HttpCode(200) + async onMattermostDialog(@Body() dto: DialogResponse, @Param('slug') slug: string) { + return this.service.onMattermostDialog(dto, slug); + } } diff --git a/src/discord/commands.ts b/src/discord/commands.ts index e73c65f..d2f5e6e 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -609,13 +609,13 @@ export class DiscordCommands { return interaction.respond(results); } - const message = await this.scheduledMessageService.removeScheduledMessage(name); + const message = await this.scheduledMessageService.removeScheduledMessage(name, 'discord'); return interaction.reply(message); } @Slash({ name: 'schedule-list', description: 'List all scheduled messages' }) async handleScheduleList(interaction: CommandInteraction) { - const messages = await this.scheduledMessageService.listScheduledMessages(); + const messages = await this.scheduledMessageService.listScheduledMessages('discord'); if (messages.length === 0) { return interaction.reply({ content: 'No scheduled messages found.', flags: [MessageFlags.Ephemeral] }); diff --git a/src/interfaces/database.interface.ts b/src/interfaces/database.interface.ts index da679db..437dd70 100644 --- a/src/interfaces/database.interface.ts +++ b/src/interfaces/database.interface.ts @@ -51,8 +51,8 @@ export interface IDatabaseRepository { getRSSFeeds(channelId?: string): Promise; removeRSSFeed(url: string, channelId: string): Promise; updateRSSFeed(entity: UpdateRSSFeed): Promise; - getScheduledMessages(): Promise; - getScheduledMessage(name: string): Promise; + getScheduledMessages(service?: 'discord' | 'mattermost'): Promise; + getScheduledMessage(name: string, service: 'discord' | 'mattermost'): Promise; createScheduledMessage(entity: NewScheduledMessage): Promise; updateScheduledMessage(entity: UpdateScheduledMessage & { name: string }): Promise; removeScheduledMessage(id: string): Promise; diff --git a/src/interfaces/mattermost.interface.ts b/src/interfaces/mattermost.interface.ts index f822083..a95839c 100644 --- a/src/interfaces/mattermost.interface.ts +++ b/src/interfaces/mattermost.interface.ts @@ -30,6 +30,105 @@ export type CommandWebhookRequest = { }; }; +type DateConfig = { + min_date?: Date | string; + max_date?: Date | string; +}; +type DatetimeConfig = DateConfig & { + time_interval?: number; + location_timezone?: string; + manual_time_entry?: boolean; +}; + +type DialogBaseField = { + /* max length 24 */ + display_name: string; + name: string; + optional?: boolean; + help_text?: string; + default?: string; + placeholder?: string; +}; +type TextBaseField = DialogBaseField & { + subtype?: 'text' | 'email' | 'number' | 'password' | 'tel' | 'url'; + min_length?: number; + max_length?: boolean; +}; +type DialogTextField = TextBaseField & { type: 'text' }; +type DialogTextareaField = TextBaseField & { type: 'textarea' }; +type DialogSelectField = DialogBaseField & { type: 'select'; multiselect?: boolean; refresh?: boolean } & ( + | { data_source: 'users' | 'channels' } + | { data_source: 'dynamic'; data_source_url: `https://${string}/plugins/${string}` } + | { data_source?: undefined; options: Array<{ text: string; value: string }> } + ); +type DialogCheckboxField = DialogBaseField & { type: 'bool' }; +type DialogRadioField = Omit & { + type: 'radio'; + options: Array<{ text: string; value: string }>; +}; +type DialogDateField = DialogBaseField & { + type: 'date'; + datetime_config: DateConfig; +}; +type DialogDatetimeField = DialogBaseField & { + type: 'datetime'; + datetime_config: DatetimeConfig; +}; +type DialogFileField = DialogBaseField & { type: 'file'; allow_multiple?: boolean }; +type DialogActionButtonField = { + display_name: string; + name: string; + type: 'action_button'; + action_button: { url: string; context?: Record }; +}; + +type DialogField = + | DialogTextField + | DialogTextareaField + | DialogSelectField + | DialogCheckboxField + | DialogRadioField + | DialogDateField + | DialogDatetimeField + | DialogFileField + | DialogActionButtonField; + +export type Dialog = { + /* max length 24 */ + title: string; + introduction_text?: string; + elements: Array; + icon_url?: string; + submit_label?: string; + notify_on_cancel?: boolean; + state?: string; + source_url?: string; +}; +export type DialogResponse = { + type: 'dialog_submission'; + callback_id: string; + state: string; + user_id: string; + channel_id: string; + team_id: string; + submission: Record; + file_ids?: string[]; + cancelled: boolean; +}; +type ResponseType = (T['elements'][number] & { name: K })['type'] extends 'bool' + ? boolean + : string; +export type DialogData = + | { cancelled: true } + | ({ cancelled: false } & { + [name in Exclude['name']]: ResponseType; + } & { + [name in Exclude['name']]?: ResponseType; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + } & ('file' extends Exclude['type'] ? { file_ids: string[] } : {}) & + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + ('file' extends T['elements'][number]['type'] ? { file_ids?: string[] } : {})); + type ParameterType = 'text' | 'userMention' | 'channelMention' | 'number'; export type CommandParameters = Array<{ name: string; type: ParameterType; optional: boolean }>; type RequiredProps = 'display_name' | 'description' | 'trigger' | 'team_id'; @@ -58,4 +157,6 @@ export interface IMattermostInterface { handler: (data: CommandWebhookRequest) => unknown, ) => Promise; runCommand: (id: string, data: CommandWebhookRequest) => Promise; + openDialog: (triggerId: string, dialog: T) => Promise>; + submitDialog: (dto: DialogResponse, slug: string) => void; } diff --git a/src/repositories/database.repository.ts b/src/repositories/database.repository.ts index da64192..b6db257 100644 --- a/src/repositories/database.repository.ts +++ b/src/repositories/database.repository.ts @@ -214,12 +214,21 @@ export class DatabaseRepository implements IDatabaseRepository { .execute(); } - getScheduledMessages(): Promise { - return this.db.selectFrom('scheduled_message').selectAll().execute(); + getScheduledMessages(service?: 'discord' | 'mattermost'): Promise { + return this.db + .selectFrom('scheduled_message') + .selectAll() + .$if(service !== undefined, (qb) => qb.where('service', '=', service!)) + .execute(); } - getScheduledMessage(name: string): Promise { - return this.db.selectFrom('scheduled_message').where('name', '=', name).selectAll().executeTakeFirst(); + getScheduledMessage(name: string, service: 'discord' | 'mattermost'): Promise { + return this.db + .selectFrom('scheduled_message') + .where('name', '=', name) + .where('service', '=', service) + .selectAll() + .executeTakeFirst(); } createScheduledMessage(entity: NewScheduledMessage): Promise { diff --git a/src/repositories/mattermost.repository.ts b/src/repositories/mattermost.repository.ts index 010248a..4163e4f 100644 --- a/src/repositories/mattermost.repository.ts +++ b/src/repositories/mattermost.repository.ts @@ -5,6 +5,9 @@ import { CommandCreate, CommandParameters, CommandWebhookRequest, + Dialog, + DialogData, + DialogResponse, IMattermostInterface, MattermostEvents, Post, @@ -22,6 +25,7 @@ export class MattermostRepository implements IMattermostInterface { string, { parameters?: CommandParameters; handler: (data: CommandWebhookRequest) => unknown } > = {}; + #modalHandlers: Record void> = {}; constructor() { const { mattermost } = getConfig(); @@ -246,4 +250,37 @@ export class MattermostRepository implements IMattermostInterface { async joinChannel(channelId: string) { await this.#client.addToChannel(this.#user.id, channelId); } + + async openDialog(triggerId: string, dialog: T) { + const id = crypto.randomUUID(); + const { promise, reject, resolve } = Promise.withResolvers>(); + + this.#modalHandlers[id] = (response) => { + if (response.cancelled) { + resolve({ cancelled: true }); + } else { + resolve({ ...response.submission, cancelled: false, file_ids: response.file_ids } as DialogData); + } + }; + + try { + await fetch(`${this.#client.getUrl()}/api/v4/actions/dialogs/open`, { + method: 'POST', + headers: { Authorization: `Bearer ${this.#client.getToken()}` }, + body: JSON.stringify({ + url: `https://discord-webhooks.immich.cloud/webhooks/mattermost/dialog/${id}`, + trigger_id: triggerId, + dialog: { ...dialog, notify_on_cancel: true }, + }), + }); + } catch (error) { + reject(error); + } + + return promise; + } + + submitDialog(dto: DialogResponse, slug: string) { + this.#modalHandlers[slug](dto); + } } diff --git a/src/schema/migrations/1788800743598-ScheduledMessagesCompositePK.ts b/src/schema/migrations/1788800743598-ScheduledMessagesCompositePK.ts new file mode 100644 index 0000000..d1c47f5 --- /dev/null +++ b/src/schema/migrations/1788800743598-ScheduledMessagesCompositePK.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "scheduled_message" DROP CONSTRAINT "scheduled_message_pkey";`.execute(db); + await sql`ALTER TABLE "scheduled_message" ADD CONSTRAINT "scheduled_message_pkey" PRIMARY KEY ("id", "service");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "scheduled_message" DROP CONSTRAINT "scheduled_message_pkey";`.execute(db); + await sql`ALTER TABLE "scheduled_message" ADD CONSTRAINT "scheduled_message_pkey" PRIMARY KEY ("id");`.execute(db); +} diff --git a/src/schema/tables/scheduled-messages.table.ts b/src/schema/tables/scheduled-messages.table.ts index d704583..87fc59d 100644 --- a/src/schema/tables/scheduled-messages.table.ts +++ b/src/schema/tables/scheduled-messages.table.ts @@ -1,8 +1,8 @@ -import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; +import { Column, CreateDateColumn, Generated, GeneratedColumn, Table } from '@immich/sql-tools'; @Table('scheduled_message') export class ScheduledMessageTable { - @PrimaryGeneratedColumn() + @GeneratedColumn({ primary: true }) id!: Generated; @Column() @@ -23,7 +23,7 @@ export class ScheduledMessageTable { @Column({ unique: true }) name!: string; - @Column({ default: 'discord' }) + @Column({ default: 'discord', primary: true }) service!: 'discord' | 'mattermost'; @CreateDateColumn() diff --git a/src/services/chat.service.spec.ts b/src/services/chat.service.spec.ts index 9ab92c9..48747e9 100644 --- a/src/services/chat.service.spec.ts +++ b/src/services/chat.service.spec.ts @@ -95,6 +95,8 @@ const newMattermostMockRepository = (): Mocked => ({ joinChannel: vitest.fn(), registerCommand: vitest.fn() as any, runCommand: vitest.fn(), + openDialog: vitest.fn(), + submitDialog: vitest.fn(), }); const newFourthwallMockRepository = (): Mocked => ({ diff --git a/src/services/scheduled-message.service.spec.ts b/src/services/scheduled-message.service.spec.ts index 8b9af18..75aba69 100644 --- a/src/services/scheduled-message.service.spec.ts +++ b/src/services/scheduled-message.service.spec.ts @@ -38,6 +38,8 @@ const newMattermostMock = (): Mocked => ({ send: vitest.fn(), streamChannels: vitest.fn(), updatePost: vitest.fn(), + openDialog: vitest.fn(), + submitDialog: vitest.fn(), }); const makeScheduledMessage = (overrides: Partial = {}): ScheduledMessage => ({ @@ -117,7 +119,7 @@ describe('ScheduledMessageService', () => { it('should return not-found message when name does not exist', async () => { databaseMock.getScheduledMessage.mockResolvedValue(undefined); - const result = await sut.removeScheduledMessage('nonexistent'); + const result = await sut.removeScheduledMessage('nonexistent', 'discord'); expect(result).toEqual('Scheduled message not found'); expect(databaseMock.removeScheduledMessage).not.toHaveBeenCalled(); @@ -127,7 +129,7 @@ describe('ScheduledMessageService', () => { const msg = makeScheduledMessage({ id: 'rm-1', name: 'to-remove' }); databaseMock.getScheduledMessage.mockResolvedValue(msg); - const result = await sut.removeScheduledMessage('to-remove'); + const result = await sut.removeScheduledMessage('to-remove', 'mattermost'); expect(databaseMock.removeScheduledMessage).toHaveBeenCalledWith('rm-1'); expect(result).toEqual('Removed scheduled message `to-remove`'); @@ -189,7 +191,7 @@ describe('ScheduledMessageService', () => { const messages = [makeScheduledMessage({ channelId: 'ch-1' }), makeScheduledMessage({ channelId: 'ch-2' })]; databaseMock.getScheduledMessages.mockResolvedValue(messages); - const result = await sut.listScheduledMessages(); + const result = await sut.listScheduledMessages('discord'); expect(result).toHaveLength(2); }); diff --git a/src/services/scheduled-message.service.ts b/src/services/scheduled-message.service.ts index 535d962..206ea6d 100644 --- a/src/services/scheduled-message.service.ts +++ b/src/services/scheduled-message.service.ts @@ -83,13 +83,85 @@ export class ScheduledMessageService { parameters: [{ name: 'name', type: 'text', optional: false }], }, async ({ parameters: { name } }) => { - const message = await this.removeScheduledMessage(name); + const message = await this.removeScheduledMessage(name, 'mattermost'); return { response_type: 'in_channel', text: message, }; }, ); + + await this.mattermost.registerCommand( + { + trigger: 'schedule-list', + display_name: 'Schedule lists', + description: 'List all scheduled messages', + auto_complete: true, + team_id: Constants.Mattermost.Teams.Immich, + }, + async () => { + const messages = await this.listScheduledMessages('mattermost'); + + if (messages.length === 0) { + return { text: 'No scheduled messages found.' }; + } + + return { + text: messages + .map( + (message) => + `- **${message.name}**: ${inlineCode(message.cronExpression)} in ~${message.channelId}: ${message.message}`, + ) + .join('\n'), + }; + }, + ); + + await this.mattermost.registerCommand( + { + trigger: 'schedule-edit', + display_name: 'Edit schedule', + description: 'Edit an existing scheduled message', + team_id: Constants.Mattermost.Teams.Immich, + auto_complete: true, + auto_complete_desc: 'The name of the scheduled message to edit', + parameters: [{ name: 'name', type: 'text', optional: false }], + }, + async ({ trigger_id, parameters: { name } }) => { + const message = await this.database.getScheduledMessage(name, 'mattermost'); + if (!message) { + return 'Scheduled message not found'; + } + + void (async () => { + const response = await this.mattermost.openDialog(trigger_id, { + title: message.name, + elements: [ + { + type: 'text', + display_name: 'Cron expression', + name: 'cronExpression', + default: message.cronExpression, + }, + { type: 'textarea', display_name: 'Message', name: 'message', default: message.message }, + { + type: 'bool', + display_name: 'Suppress embeds', + name: 'suppressEmbeds', + optional: true, + default: String(message.suppressEmbeds), + }, + ], + }); + + if (response.cancelled) { + return; + } + + await this.database.updateScheduledMessage({ name, ...response }); + })(); + }, + ); } private registerJob({ @@ -144,7 +216,7 @@ export class ScheduledMessageService { } async editScheduledMessage(name: string) { - const message = await this.database.getScheduledMessage(name); + const message = await this.database.getScheduledMessage(name, 'discord'); if (!message) { return 'Scheduled message not found'; } @@ -202,8 +274,8 @@ export class ScheduledMessageService { await interaction.reply(`Successfully updated scheduled message ${inlineCode(updatedMessage.name)}`); } - async removeScheduledMessage(name: string) { - const message = await this.database.getScheduledMessage(name); + async removeScheduledMessage(name: string, service: 'discord' | 'mattermost') { + const message = await this.database.getScheduledMessage(name, service); if (!message) { return 'Scheduled message not found'; } @@ -219,7 +291,7 @@ export class ScheduledMessageService { } async getScheduledMessages(value?: string) { - let messages = await this.database.getScheduledMessages(); + let messages = await this.database.getScheduledMessages('discord'); if (value) { const query = value.toLowerCase(); messages = messages.filter(({ name }) => name.toLowerCase().includes(query)); @@ -233,7 +305,7 @@ export class ScheduledMessageService { .slice(0, 25); } - async listScheduledMessages() { - return this.database.getScheduledMessages(); + async listScheduledMessages(service: 'discord' | 'mattermost') { + return this.database.getScheduledMessages(service); } } diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index a7d6c3c..9095f33 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -18,7 +18,7 @@ import { IFourthwallRepository, } from 'src/interfaces/fourthwall.interface'; import { IGithubInterface } from 'src/interfaces/github.interface'; -import { CommandWebhookRequest, IMattermostInterface } from 'src/interfaces/mattermost.interface'; +import { CommandWebhookRequest, DialogResponse, IMattermostInterface } from 'src/interfaces/mattermost.interface'; import { IOutlineInterface } from 'src/interfaces/outline.interface'; import { IZulipInterface } from 'src/interfaces/zulip.interface'; import { FourthwallRepository } from 'src/repositories/fourthwall.repository'; @@ -244,6 +244,10 @@ export class WebhookService { return this.mattermost.runCommand(slug, dto); } + async onMattermostDialog(dto: DialogResponse, slug: string) { + return this.mattermost.submitDialog(dto, slug); + } + private async handleFourthwallOrder(dto: FourthwallOrderCreateWebhook | FourthwallOrderUpdateWebhook) { const { fourthwall } = getConfig(); diff --git a/tsconfig.json b/tsconfig.json index 9e578f3..c38b4cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "node20", + "module": "nodenext", "strict": true, "declaration": true, "removeComments": true, @@ -8,9 +8,9 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, - "target": "es2022", - "moduleResolution": "node16", - "lib": ["dom", "es2023"], + "target": "esnext", + "moduleResolution": "nodenext", + "lib": ["dom", "ESNext"], "sourceMap": true, "outDir": "./dist", "rootDir": "./src",