feat: mattermost modals (#621)

This commit is contained in:
Daniel Dietzler
2026-09-11 21:22:05 +02:00
committed by GitHub
parent ddda30b019
commit 733a2a0d72
13 changed files with 271 additions and 27 deletions
+7 -1
View File
@@ -3,7 +3,7 @@ import type { EmitterWebhookEvent } from '@octokit/webhooks';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { GithubStatusComponent, GithubStatusIncident, StripeBase } from 'src/dtos/webhook.dto'; import { GithubStatusComponent, GithubStatusIncident, StripeBase } from 'src/dtos/webhook.dto';
import { FourthwallOrderCreateWebhook, FourthwallOrderUpdateWebhook } from 'src/interfaces/fourthwall.interface'; 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'; import { WebhookService } from 'src/services/webhook.service';
@Injectable() @Injectable()
@@ -54,4 +54,10 @@ export class WebhookController {
async onMattermostCommand(@Body() dto: CommandWebhookRequest<never>, @Param('slug') slug: string) { async onMattermostCommand(@Body() dto: CommandWebhookRequest<never>, @Param('slug') slug: string) {
return this.service.onMattermostCommand(dto, slug); 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);
}
} }
+2 -2
View File
@@ -609,13 +609,13 @@ export class DiscordCommands {
return interaction.respond(results); return interaction.respond(results);
} }
const message = await this.scheduledMessageService.removeScheduledMessage(name); const message = await this.scheduledMessageService.removeScheduledMessage(name, 'discord');
return interaction.reply(message); return interaction.reply(message);
} }
@Slash({ name: 'schedule-list', description: 'List all scheduled messages' }) @Slash({ name: 'schedule-list', description: 'List all scheduled messages' })
async handleScheduleList(interaction: CommandInteraction) { async handleScheduleList(interaction: CommandInteraction) {
const messages = await this.scheduledMessageService.listScheduledMessages(); const messages = await this.scheduledMessageService.listScheduledMessages('discord');
if (messages.length === 0) { if (messages.length === 0) {
return interaction.reply({ content: 'No scheduled messages found.', flags: [MessageFlags.Ephemeral] }); return interaction.reply({ content: 'No scheduled messages found.', flags: [MessageFlags.Ephemeral] });
+2 -2
View File
@@ -51,8 +51,8 @@ export interface IDatabaseRepository {
getRSSFeeds(channelId?: string): Promise<RSSFeed[]>; getRSSFeeds(channelId?: string): Promise<RSSFeed[]>;
removeRSSFeed(url: string, channelId: string): Promise<void>; removeRSSFeed(url: string, channelId: string): Promise<void>;
updateRSSFeed(entity: UpdateRSSFeed): Promise<void>; updateRSSFeed(entity: UpdateRSSFeed): Promise<void>;
getScheduledMessages(): Promise<ScheduledMessage[]>; getScheduledMessages(service?: 'discord' | 'mattermost'): Promise<ScheduledMessage[]>;
getScheduledMessage(name: string): Promise<ScheduledMessage | undefined>; getScheduledMessage(name: string, service: 'discord' | 'mattermost'): Promise<ScheduledMessage | undefined>;
createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage>; createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage>;
updateScheduledMessage(entity: UpdateScheduledMessage & { name: string }): Promise<ScheduledMessage | undefined>; updateScheduledMessage(entity: UpdateScheduledMessage & { name: string }): Promise<ScheduledMessage | undefined>;
removeScheduledMessage(id: string): Promise<void>; removeScheduledMessage(id: string): Promise<void>;
+101
View File
@@ -30,6 +30,105 @@ export type CommandWebhookRequest<T extends CommandParameters> = {
}; };
}; };
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<DialogBaseField, 'placeholder' | 'optional'> & {
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<string, unknown> };
};
type DialogField =
| DialogTextField
| DialogTextareaField
| DialogSelectField
| DialogCheckboxField
| DialogRadioField
| DialogDateField
| DialogDatetimeField
| DialogFileField
| DialogActionButtonField;
export type Dialog = {
/* max length 24 */
title: string;
introduction_text?: string;
elements: Array<DialogField>;
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<string, string>;
file_ids?: string[];
cancelled: boolean;
};
type ResponseType<T extends Dialog, K> = (T['elements'][number] & { name: K })['type'] extends 'bool'
? boolean
: string;
export type DialogData<T extends Dialog> =
| { cancelled: true }
| ({ cancelled: false } & {
[name in Exclude<T['elements'][number], { type: 'file' } | { optional: true }>['name']]: ResponseType<T, name>;
} & {
[name in Exclude<T['elements'][number], { type: 'file' }>['name']]?: ResponseType<T, name>;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
} & ('file' extends Exclude<T['elements'][number], { optional: true }>['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'; type ParameterType = 'text' | 'userMention' | 'channelMention' | 'number';
export type CommandParameters = Array<{ name: string; type: ParameterType; optional: boolean }>; export type CommandParameters = Array<{ name: string; type: ParameterType; optional: boolean }>;
type RequiredProps = 'display_name' | 'description' | 'trigger' | 'team_id'; type RequiredProps = 'display_name' | 'description' | 'trigger' | 'team_id';
@@ -58,4 +157,6 @@ export interface IMattermostInterface {
handler: (data: CommandWebhookRequest<T>) => unknown, handler: (data: CommandWebhookRequest<T>) => unknown,
) => Promise<void>; ) => Promise<void>;
runCommand: (id: string, data: CommandWebhookRequest<never>) => Promise<unknown>; runCommand: (id: string, data: CommandWebhookRequest<never>) => Promise<unknown>;
openDialog: <const T extends Dialog>(triggerId: string, dialog: T) => Promise<DialogData<T>>;
submitDialog: (dto: DialogResponse, slug: string) => void;
} }
+13 -4
View File
@@ -214,12 +214,21 @@ export class DatabaseRepository implements IDatabaseRepository {
.execute(); .execute();
} }
getScheduledMessages(): Promise<ScheduledMessage[]> { getScheduledMessages(service?: 'discord' | 'mattermost'): Promise<ScheduledMessage[]> {
return this.db.selectFrom('scheduled_message').selectAll().execute(); return this.db
.selectFrom('scheduled_message')
.selectAll()
.$if(service !== undefined, (qb) => qb.where('service', '=', service!))
.execute();
} }
getScheduledMessage(name: string): Promise<ScheduledMessage | undefined> { getScheduledMessage(name: string, service: 'discord' | 'mattermost'): Promise<ScheduledMessage | undefined> {
return this.db.selectFrom('scheduled_message').where('name', '=', name).selectAll().executeTakeFirst(); return this.db
.selectFrom('scheduled_message')
.where('name', '=', name)
.where('service', '=', service)
.selectAll()
.executeTakeFirst();
} }
createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage> { createScheduledMessage(entity: NewScheduledMessage): Promise<ScheduledMessage> {
+37
View File
@@ -5,6 +5,9 @@ import {
CommandCreate, CommandCreate,
CommandParameters, CommandParameters,
CommandWebhookRequest, CommandWebhookRequest,
Dialog,
DialogData,
DialogResponse,
IMattermostInterface, IMattermostInterface,
MattermostEvents, MattermostEvents,
Post, Post,
@@ -22,6 +25,7 @@ export class MattermostRepository implements IMattermostInterface {
string, string,
{ parameters?: CommandParameters; handler: (data: CommandWebhookRequest<never>) => unknown } { parameters?: CommandParameters; handler: (data: CommandWebhookRequest<never>) => unknown }
> = {}; > = {};
#modalHandlers: Record<string, (data: DialogResponse) => void> = {};
constructor() { constructor() {
const { mattermost } = getConfig(); const { mattermost } = getConfig();
@@ -246,4 +250,37 @@ export class MattermostRepository implements IMattermostInterface {
async joinChannel(channelId: string) { async joinChannel(channelId: string) {
await this.#client.addToChannel(this.#user.id, channelId); await this.#client.addToChannel(this.#user.id, channelId);
} }
async openDialog<const T extends Dialog>(triggerId: string, dialog: T) {
const id = crypto.randomUUID();
const { promise, reject, resolve } = Promise.withResolvers<DialogData<T>>();
this.#modalHandlers[id] = (response) => {
if (response.cancelled) {
resolve({ cancelled: true });
} else {
resolve({ ...response.submission, cancelled: false, file_ids: response.file_ids } as DialogData<T>);
}
};
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);
}
} }
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -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') @Table('scheduled_message')
export class ScheduledMessageTable { export class ScheduledMessageTable {
@PrimaryGeneratedColumn() @GeneratedColumn({ primary: true })
id!: Generated<string>; id!: Generated<string>;
@Column() @Column()
@@ -23,7 +23,7 @@ export class ScheduledMessageTable {
@Column({ unique: true }) @Column({ unique: true })
name!: string; name!: string;
@Column({ default: 'discord' }) @Column({ default: 'discord', primary: true })
service!: 'discord' | 'mattermost'; service!: 'discord' | 'mattermost';
@CreateDateColumn() @CreateDateColumn()
+2
View File
@@ -95,6 +95,8 @@ const newMattermostMockRepository = (): Mocked<IMattermostInterface> => ({
joinChannel: vitest.fn(), joinChannel: vitest.fn(),
registerCommand: vitest.fn() as any, registerCommand: vitest.fn() as any,
runCommand: vitest.fn(), runCommand: vitest.fn(),
openDialog: vitest.fn(),
submitDialog: vitest.fn(),
}); });
const newFourthwallMockRepository = (): Mocked<IFourthwallRepository> => ({ const newFourthwallMockRepository = (): Mocked<IFourthwallRepository> => ({
@@ -38,6 +38,8 @@ const newMattermostMock = (): Mocked<IMattermostInterface> => ({
send: vitest.fn(), send: vitest.fn(),
streamChannels: vitest.fn(), streamChannels: vitest.fn(),
updatePost: vitest.fn(), updatePost: vitest.fn(),
openDialog: vitest.fn(),
submitDialog: vitest.fn(),
}); });
const makeScheduledMessage = (overrides: Partial<ScheduledMessage> = {}): ScheduledMessage => ({ const makeScheduledMessage = (overrides: Partial<ScheduledMessage> = {}): ScheduledMessage => ({
@@ -117,7 +119,7 @@ describe('ScheduledMessageService', () => {
it('should return not-found message when name does not exist', async () => { it('should return not-found message when name does not exist', async () => {
databaseMock.getScheduledMessage.mockResolvedValue(undefined); 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(result).toEqual('Scheduled message not found');
expect(databaseMock.removeScheduledMessage).not.toHaveBeenCalled(); expect(databaseMock.removeScheduledMessage).not.toHaveBeenCalled();
@@ -127,7 +129,7 @@ describe('ScheduledMessageService', () => {
const msg = makeScheduledMessage({ id: 'rm-1', name: 'to-remove' }); const msg = makeScheduledMessage({ id: 'rm-1', name: 'to-remove' });
databaseMock.getScheduledMessage.mockResolvedValue(msg); 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(databaseMock.removeScheduledMessage).toHaveBeenCalledWith('rm-1');
expect(result).toEqual('Removed scheduled message `to-remove`'); expect(result).toEqual('Removed scheduled message `to-remove`');
@@ -189,7 +191,7 @@ describe('ScheduledMessageService', () => {
const messages = [makeScheduledMessage({ channelId: 'ch-1' }), makeScheduledMessage({ channelId: 'ch-2' })]; const messages = [makeScheduledMessage({ channelId: 'ch-1' }), makeScheduledMessage({ channelId: 'ch-2' })];
databaseMock.getScheduledMessages.mockResolvedValue(messages); databaseMock.getScheduledMessages.mockResolvedValue(messages);
const result = await sut.listScheduledMessages(); const result = await sut.listScheduledMessages('discord');
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
+79 -7
View File
@@ -83,13 +83,85 @@ export class ScheduledMessageService {
parameters: [{ name: 'name', type: 'text', optional: false }], parameters: [{ name: 'name', type: 'text', optional: false }],
}, },
async ({ parameters: { name } }) => { async ({ parameters: { name } }) => {
const message = await this.removeScheduledMessage(name); const message = await this.removeScheduledMessage(name, 'mattermost');
return { return {
response_type: 'in_channel', response_type: 'in_channel',
text: message, 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({ private registerJob({
@@ -144,7 +216,7 @@ export class ScheduledMessageService {
} }
async editScheduledMessage(name: string) { async editScheduledMessage(name: string) {
const message = await this.database.getScheduledMessage(name); const message = await this.database.getScheduledMessage(name, 'discord');
if (!message) { if (!message) {
return 'Scheduled message not found'; return 'Scheduled message not found';
} }
@@ -202,8 +274,8 @@ export class ScheduledMessageService {
await interaction.reply(`Successfully updated scheduled message ${inlineCode(updatedMessage.name)}`); await interaction.reply(`Successfully updated scheduled message ${inlineCode(updatedMessage.name)}`);
} }
async removeScheduledMessage(name: string) { async removeScheduledMessage(name: string, service: 'discord' | 'mattermost') {
const message = await this.database.getScheduledMessage(name); const message = await this.database.getScheduledMessage(name, service);
if (!message) { if (!message) {
return 'Scheduled message not found'; return 'Scheduled message not found';
} }
@@ -219,7 +291,7 @@ export class ScheduledMessageService {
} }
async getScheduledMessages(value?: string) { async getScheduledMessages(value?: string) {
let messages = await this.database.getScheduledMessages(); let messages = await this.database.getScheduledMessages('discord');
if (value) { if (value) {
const query = value.toLowerCase(); const query = value.toLowerCase();
messages = messages.filter(({ name }) => name.toLowerCase().includes(query)); messages = messages.filter(({ name }) => name.toLowerCase().includes(query));
@@ -233,7 +305,7 @@ export class ScheduledMessageService {
.slice(0, 25); .slice(0, 25);
} }
async listScheduledMessages() { async listScheduledMessages(service: 'discord' | 'mattermost') {
return this.database.getScheduledMessages(); return this.database.getScheduledMessages(service);
} }
} }
+5 -1
View File
@@ -18,7 +18,7 @@ import {
IFourthwallRepository, IFourthwallRepository,
} from 'src/interfaces/fourthwall.interface'; } from 'src/interfaces/fourthwall.interface';
import { IGithubInterface } from 'src/interfaces/github.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 { IOutlineInterface } from 'src/interfaces/outline.interface';
import { IZulipInterface } from 'src/interfaces/zulip.interface'; import { IZulipInterface } from 'src/interfaces/zulip.interface';
import { FourthwallRepository } from 'src/repositories/fourthwall.repository'; import { FourthwallRepository } from 'src/repositories/fourthwall.repository';
@@ -244,6 +244,10 @@ export class WebhookService {
return this.mattermost.runCommand(slug, dto); return this.mattermost.runCommand(slug, dto);
} }
async onMattermostDialog(dto: DialogResponse, slug: string) {
return this.mattermost.submitDialog(dto, slug);
}
private async handleFourthwallOrder(dto: FourthwallOrderCreateWebhook | FourthwallOrderUpdateWebhook) { private async handleFourthwallOrder(dto: FourthwallOrderCreateWebhook | FourthwallOrderUpdateWebhook) {
const { fourthwall } = getConfig(); const { fourthwall } = getConfig();
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "node20", "module": "nodenext",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
@@ -8,9 +8,9 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2022", "target": "esnext",
"moduleResolution": "node16", "moduleResolution": "nodenext",
"lib": ["dom", "es2023"], "lib": ["dom", "ESNext"],
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",