Skip to content

Commit

Permalink
feat: custom discord messages (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
danieldietzler authored Oct 30, 2024
1 parent 7b534a3 commit 9116cd2
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 24 deletions.
12 changes: 3 additions & 9 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { DiscordChannel } from 'src/interfaces/discord.interface';

export enum DiscordModal {
Env = 'envModal',
Logs = 'logsModal',
Compose = 'composeModal',
Message = 'messageModal',
}

export enum DiscordButton {
Expand All @@ -15,6 +14,8 @@ export enum DiscordField {
Env = 'env',
Logs = 'logs',
Source = 'source',
Name = 'name',
Message = 'message',
}

export enum GithubRepo {
Expand Down Expand Up @@ -93,13 +94,6 @@ export const Constants = {
},
};

export const HELP_TEXTS = {
'docker logs': `View container logs by running \`docker compose logs\`. For further information refer to ${Constants.Urls.Docs.Docker}`,
'help ticket': `Please open a <#${DiscordChannel.HelpDesk}> ticket with more information and we can help you troubleshoot the issue.`,
'reverse proxy': `This sounds like it could be a reverse proxy issue. Here's a link to the relevant documentation page: ${Constants.Urls.Docs.ReverseProxy}.`,
'feature request': `For ideas or features you'd like Immich to have, feel free to [open a feature request in the Github discussions](${Constants.Urls.FeatureRequest}). However, please make sure to search for similar requests first to avoid duplicates.`,
};

export const ReleaseMessages = [
'A day with a release is a good day!',
'New release, new possibilities!',
Expand Down
152 changes: 142 additions & 10 deletions src/discord/commands.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Injectable } from '@nestjs/common';
import {
ActionRowBuilder,
ApplicationCommandOptionType,
AutocompleteInteraction,
MessageFlags,
ModalBuilder,
ModalSubmitInteraction,
TextInputBuilder,
TextInputStyle,
ThreadChannel,
type CommandInteraction,
} from 'discord.js';
import { Discord, Slash, SlashChoice, SlashOption } from 'discordx';
import { Constants, HELP_TEXTS } from 'src/constants';
import { Discord, ModalComponent, Slash, SlashOption } from 'discordx';
import { Constants, DiscordField, DiscordModal } from 'src/constants';
import { DiscordChannel } from 'src/interfaces/discord.interface';
import { DiscordService } from 'src/services/discord.service';

Expand Down Expand Up @@ -157,21 +162,31 @@ export class DiscordCommands {
await interaction.reply(channel.appliedTags.join(', '));
}
}

@Slash({ name: 'messages', description: 'Text blocks for reoccurring questions' })
handleMessages(
@SlashChoice(...Object.keys(HELP_TEXTS))
async handleMessages(
@SlashOption({
description: 'Which message do you need',
description: 'Which message do you need?',
name: 'type',
required: true,
autocomplete: true,
type: ApplicationCommandOptionType.String,
})
name: keyof typeof HELP_TEXTS,
interaction: CommandInteraction,
name: string,
interaction: CommandInteraction | AutocompleteInteraction,
) {
const message = this.service.getHelpMessage(name);
return interaction.reply({ content: message, flags: [MessageFlags.SuppressEmbeds] });
if (interaction.isAutocomplete()) {
const value = interaction.options.getFocused(true).value;
const message = await this.service.getMessages(value);
return interaction.respond(message);
}

const message = await this.service.getMessage(name);

if (!message) {
return interaction.reply({ content: 'Message could not be found', ephemeral: true });
}

return interaction.reply({ content: message.content, flags: [MessageFlags.SuppressEmbeds] });
}

@Slash({ name: 'search', description: 'Search for PRs and Issues by title' })
Expand All @@ -195,4 +210,121 @@ export class DiscordCommands {
const content = await this.service.getPrOrIssue(Number(id));
return interaction.reply({ content, flags: [MessageFlags.SuppressEmbeds] });
}

@Slash({ name: 'message-add', description: 'Add a new message' })
async handleMessageAdd(interaction: CommandInteraction) {
if (!(await authGuard(interaction))) {
return;
}

const modal = new ModalBuilder({ customId: DiscordModal.Message, title: 'Add message' }).addComponents(
new ActionRowBuilder<TextInputBuilder>({
components: [
new TextInputBuilder({ customId: DiscordField.Name, label: 'Message name', style: TextInputStyle.Short }),
],
}),
new ActionRowBuilder<TextInputBuilder>({
components: [
new TextInputBuilder({
customId: DiscordField.Message,
label: 'Message content',
style: TextInputStyle.Paragraph,
}),
],
}),
);

return interaction.showModal(modal);
}

@Slash({ name: 'message-edit', description: 'Edit a message' })
async handleMessageEdit(
@SlashOption({
name: 'name',
description: 'The name of the message to edit',
required: true,
type: ApplicationCommandOptionType.String,
autocomplete: true,
})
messageName: string,
interaction: CommandInteraction | AutocompleteInteraction,
) {
if (interaction.isAutocomplete()) {
const value = interaction.options.getFocused(true).value;
const message = await this.service.getMessages(value);
return interaction.respond(message);
}

if (!(await authGuard(interaction))) {
return;
}

const message = await this.service.getMessage(messageName);

if (!message) {
return interaction.reply({ content: 'Message could not be found', ephemeral: true });
}

const modal = new ModalBuilder({ customId: DiscordModal.Message, title: 'Edit message' }).addComponents(
new ActionRowBuilder<TextInputBuilder>({
components: [
new TextInputBuilder({
customId: DiscordField.Name,
label: 'Message name',
style: TextInputStyle.Short,
value: message.name,
}),
],
}),
new ActionRowBuilder<TextInputBuilder>({
components: [
new TextInputBuilder({
customId: DiscordField.Message,
label: 'Message content',
style: TextInputStyle.Paragraph,
value: message.content,
}),
],
}),
);

return interaction.showModal(modal);
}

@Slash({ name: 'message-remove', description: 'Remove an existing message' })
async handleMessageRemove(
@SlashOption({
description: 'The name of the message to remove',
name: 'name',
required: true,
autocomplete: true,
type: ApplicationCommandOptionType.String,
})
name: string,
interaction: CommandInteraction | AutocompleteInteraction,
) {
if (interaction.isAutocomplete()) {
const value = interaction.options.getFocused(true).value;
const message = await this.service.getMessages(value);
return interaction.respond(message);
}

if (!(await authGuard(interaction))) {
return;
}

const { message, isPrivate } = await this.service.removeMessage(name);

return interaction.reply({ content: message, ephemeral: isPrivate, flags: [MessageFlags.SuppressEmbeds] });
}

@ModalComponent({ id: DiscordModal.Message })
async handleMessageModal(interaction: ModalSubmitInteraction) {
const name = interaction.fields.getTextInputValue(DiscordField.Name);
const content = interaction.fields.getTextInputValue(DiscordField.Message);

await this.service.addOrUpdateMessage({ name, content, author: interaction.user.id });

await interaction.deferUpdate();
}
}
19 changes: 19 additions & 0 deletions src/interfaces/database.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,24 @@ export type DiscordLink = Selectable<DiscordLinksTable>;
export type NewDiscordLink = Insertable<DiscordLinksTable>;
export type DiscordLinkUpdate = Updateable<DiscordLinksTable> & { id: string };

export interface DiscordMessagesTable {
id: Generated<string>;
createdAt: Generated<Date>;
lastEditedBy: string;
name: string;
content: string;
usageCount: Generated<number>;
}

export type DiscordMessage = Selectable<DiscordMessagesTable>;
export type NewDiscordMessage = Insertable<DiscordMessagesTable>;
export type UpdateDiscordMessage = Updateable<DiscordMessagesTable> & { id: string };

export interface Database {
payment: PaymentTable;
sponsor: SponsorTable;
discord_links: DiscordLinksTable;
discord_messages: DiscordMessagesTable;
}

export type LicenseCountOptions = {
Expand All @@ -76,4 +90,9 @@ export interface IDatabaseRepository {
addDiscordLink(link: NewDiscordLink): Promise<void>;
removeDiscordLink(id: string): Promise<void>;
updateDiscordLink(link: DiscordLinkUpdate): Promise<void>;
getDiscordMessages(): Promise<DiscordMessage[]>;
getDiscordMessage(name: string): Promise<DiscordMessage | undefined>;
addDiscordMessage(message: NewDiscordMessage): Promise<void>;
updateDiscordMessage(message: UpdateDiscordMessage): Promise<void>;
removeDiscordMessage(id: string): Promise<void>;
}
17 changes: 17 additions & 0 deletions src/migrations/1730152265-createDiscordMessagesTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('discord_messages')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('createdAt', 'varchar', (col) => col.notNull().defaultTo(sql`NOW()`))
.addColumn('lastEditedBy', 'varchar', (col) => col.notNull())
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('content', 'varchar', (col) => col.notNull().unique())
.addColumn('usageCount', 'integer', (col) => col.notNull().defaultTo(0))
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('discord_messages').execute();
}
23 changes: 23 additions & 0 deletions src/repositories/database.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import {
Database,
DiscordLink,
DiscordLinkUpdate,
DiscordMessage,
IDatabaseRepository,
LicenseCountOptions,
LicenseType,
NewDiscordLink,
NewDiscordMessage,
NewPayment,
UpdateDiscordMessage,
} from 'src/interfaces/database.interface';

export class DatabaseRepository implements IDatabaseRepository {
Expand Down Expand Up @@ -135,4 +138,24 @@ export class DatabaseRepository implements IDatabaseRepository {
async updateDiscordLink({ id, ...link }: DiscordLinkUpdate) {
await this.db.updateTable('discord_links').set(link).where('id', '=', id).execute();
}

getDiscordMessages(): Promise<DiscordMessage[]> {
return this.db.selectFrom('discord_messages').selectAll().execute();
}

getDiscordMessage(name: string): Promise<DiscordMessage | undefined> {
return this.db.selectFrom('discord_messages').where('name', '=', name).selectAll().executeTakeFirst();
}

async addDiscordMessage(message: NewDiscordMessage): Promise<void> {
await this.db.insertInto('discord_messages').values(message).execute();
}

async removeDiscordMessage(id: string): Promise<void> {
await this.db.deleteFrom('discord_messages').where('id', '=', id).execute();
}

async updateDiscordMessage({ id, ...message }: UpdateDiscordMessage): Promise<void> {
await this.db.updateTable('discord_messages').set(message).where('id', '=', id).execute();
}
}
5 changes: 5 additions & 0 deletions src/services/discord.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const newDatabaseMockRepository = (): Mocked<IDatabaseRepository> => ({
getTotalLicenseCount: vitest.fn(),
runMigrations: vitest.fn(),
updateDiscordLink: vitest.fn(),
addDiscordMessage: vitest.fn(),
getDiscordMessage: vitest.fn(),
getDiscordMessages: vitest.fn(),
removeDiscordMessage: vitest.fn(),
updateDiscordMessage: vitest.fn(),
});

describe('Bot test', () => {
Expand Down
51 changes: 46 additions & 5 deletions src/services/discord.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getConfig } from 'src/config';
import { Constants, GithubOrg, GithubRepo, HELP_TEXTS } from 'src/constants';
import { Constants, GithubOrg, GithubRepo } from 'src/constants';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { DiscordChannel, IDiscordInterface } from 'src/interfaces/discord.interface';
import { IGithubInterface } from 'src/interfaces/github.interface';
Expand Down Expand Up @@ -75,10 +75,6 @@ export class DiscordService {
await logError('Discord bot error', error, { discord: this.discord, logger: this.logger });
}

getHelpMessage(name: keyof typeof HELP_TEXTS) {
return HELP_TEXTS[name];
}

async getLink(name: string, message: string | null) {
const item = await this.database.getDiscordLink(name);
if (!item) {
Expand Down Expand Up @@ -278,4 +274,49 @@ export class DiscordService {
getPrOrIssue(id: number) {
return this.github.getIssueOrPr(GithubOrg.ImmichApp, GithubRepo.Immich, id);
}

async getMessages(value?: string) {
let messages = await this.database.getDiscordMessages();
if (value) {
const query = value.toLowerCase();
messages = messages.filter(({ name }) => name.toLowerCase().includes(query));
}

return messages.map(({ name, content }) => ({
name: shorten(`${name}${content}`, 40),
value: name,
}));
}

async getMessage(name: string, increaseUsageCount: boolean = true) {
const item = await this.database.getDiscordMessage(name);
if (!item) {
return;
}

if (increaseUsageCount) {
await this.database.updateDiscordLink({ id: item.id, usageCount: item.usageCount + 1 });
}

return item;
}

async removeMessage(name: string) {
const message = await this.database.getDiscordMessage(name);
if (!message) {
return LINK_NOT_FOUND;
}

await this.database.removeDiscordMessage(message.id);

return { message: shorten(`Removed ${message.name} - ${message.content}`), isPrivate: false };
}

async addOrUpdateMessage({ name, content, author }: { name: string; content: string; author: string }) {
const message = await this.database.getDiscordMessage(name);
if (message) {
return this.database.updateDiscordMessage({ id: message.id, name, content, lastEditedBy: author });
}
return this.database.addDiscordMessage({ name, content, lastEditedBy: author });
}
}

0 comments on commit 9116cd2

Please sign in to comment.