From 1042622878b163f1acf90b441f6a66bd7e6d6ad4 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Tue, 24 Sep 2024 17:46:36 +0200 Subject: [PATCH] feat: emote management --- src/app.module.ts | 3 +- src/discord/commands.ts | 38 ++++++++++++++- src/discord/context-menus.ts | 58 ++++++++++++++++++++++ src/interfaces/discord.interface.ts | 3 +- src/repositories/discord.repository.ts | 4 ++ src/services/discord.service.spec.ts | 1 + src/services/discord.service.ts | 66 ++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/discord/context-menus.ts diff --git a/src/app.module.ts b/src/app.module.ts index 9c4c622..2eccc7a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,9 +8,10 @@ import { DiscordHelpDesk } from 'src/discord/help-desk'; import { providers } from 'src/repositories'; import { services } from 'src/services'; import { DatabaseService } from 'src/services/database.service'; +import { DiscordContextMenus } from './discord/context-menus'; const middleware = [{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }]; -const discord = [DiscordCommands, DiscordEvents, DiscordHelpDesk]; +const discord = [DiscordCommands, DiscordEvents, DiscordHelpDesk, DiscordContextMenus]; @Module({ imports: [ScheduleModule.forRoot()], diff --git a/src/discord/commands.ts b/src/discord/commands.ts index 07deb4a..df81907 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -11,7 +11,7 @@ import { ThreadChannel, type CommandInteraction, } from 'discord.js'; -import { Discord, ModalComponent, Slash, SlashOption } from 'discordx'; +import { Discord, ModalComponent, Slash, SlashChoice, SlashOption } from 'discordx'; import { Constants, DiscordField, DiscordModal } from 'src/constants'; import { DiscordChannel } from 'src/interfaces/discord.interface'; import { DiscordService } from 'src/services/discord.service'; @@ -327,4 +327,40 @@ export class DiscordCommands { await interaction.deferUpdate(); } + + @Slash({ name: 'emote-add', description: 'Add new emotes to the server' }) + async handleEmoteAdd( + @SlashChoice('7tv', 'bttv') + @SlashOption({ + name: 'source', + description: 'Where the emote is from', + type: ApplicationCommandOptionType.String, + required: true, + }) + source: '7tv' | 'bttv', + @SlashOption({ + name: 'id', + description: 'ID of the emote', + type: ApplicationCommandOptionType.String, + required: true, + }) + id: string, + interaction: CommandInteraction, + ) { + let emote; + switch (source) { + case '7tv': + emote = await this.service.create7TvEmote(id, interaction.guildId); + break; + case 'bttv': + emote = await this.service.createBttvEmote(id, interaction.guildId); + } + + if (!emote) { + await interaction.reply({ content: `Could not find ${source.toUpperCase()} emote with id ${id}` }); + return; + } + + await interaction.reply({ content: `Emote successfully added! ${emote.toString()}` }); + } } diff --git a/src/discord/context-menus.ts b/src/discord/context-menus.ts new file mode 100644 index 0000000..6afb8df --- /dev/null +++ b/src/discord/context-menus.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { + ActionRowBuilder, + ApplicationCommandType, + MessageContextMenuCommandInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { ContextMenu, Discord } from 'discordx'; +import { DiscordService } from 'src/services/discord.service'; + +@Discord() +@Injectable() +export class DiscordContextMenus { + constructor(private service: DiscordService) {} + + @ContextMenu({ + name: 'Save as emote', + type: ApplicationCommandType.Message, + defaultMemberPermissions: 'ManageEmojisAndStickers', + }) + async onSaveEmote(interaction: MessageContextMenuCommandInteraction) { + const modal = new ModalBuilder({ + customId: 'emote_create_label', + title: 'Create Emote', + components: [ + new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: 'name', + label: 'Emote Name', + style: TextInputStyle.Short, + required: true, + }), + ], + }), + ], + }); + await interaction.showModal(modal); + const modalResponse = await interaction.awaitModalSubmit({ time: 9999999 }); + + const emoteUrl = interaction.targetMessage.attachments.first()?.url; + + if (!emoteUrl) { + await interaction.reply({ content: 'Could not find emote.' }); + return; + } + + const emote = await this.service.createEmote( + modalResponse.fields.getTextInputValue('name'), + emoteUrl, + interaction.guildId, + ); + console.log(emote); + await interaction.reply({ content: emote?.identifier }); + } +} diff --git a/src/interfaces/discord.interface.ts b/src/interfaces/discord.interface.ts index 8f32fae..795e839 100644 --- a/src/interfaces/discord.interface.ts +++ b/src/interfaces/discord.interface.ts @@ -1,4 +1,4 @@ -import { MessageCreateOptions } from 'discord.js'; +import { GuildEmoji, MessageCreateOptions } from 'discord.js'; export const IDiscordInterface = 'IDiscordInterface'; @@ -34,4 +34,5 @@ export interface IDiscordInterface { message: string | MessageCreateOptions; crosspost?: boolean; }): Promise; + createEmote(name: string, emote: string | Buffer, guildId: string): Promise; } diff --git a/src/repositories/discord.repository.ts b/src/repositories/discord.repository.ts index 8294acd..8650a7f 100644 --- a/src/repositories/discord.repository.ts +++ b/src/repositories/discord.repository.ts @@ -83,4 +83,8 @@ export class DiscordRepository implements IDiscordInterface { } } } + + async createEmote(name: string, emote: string | Buffer, guildId: string) { + return bot.guilds.cache.get(guildId)?.emojis.create({ name, attachment: emote }); + } } diff --git a/src/services/discord.service.spec.ts b/src/services/discord.service.spec.ts index 7f6aefb..78ff89e 100644 --- a/src/services/discord.service.spec.ts +++ b/src/services/discord.service.spec.ts @@ -21,6 +21,7 @@ const newGithubMockRepository = (): Mocked => ({ const newDiscordMockRepository = (): Mocked => ({ login: vitest.fn(), sendMessage: vitest.fn(), + createEmote: vitest.fn(), }); const newDatabaseMockRepository = (): Mocked => ({ diff --git a/src/services/discord.service.ts b/src/services/discord.service.ts index ff06e5b..5ddfcc9 100644 --- a/src/services/discord.service.ts +++ b/src/services/discord.service.ts @@ -24,6 +24,32 @@ type GithubLink = { }; type LinkType = 'issue' | 'pull' | 'discussion'; +type SevenTVResponse = { + id: string; + name: string; + host: { + url: string; + files: [ + { + name: string; + static_name: string; + width: number; + height: number; + frame_count: number; + size: number; + format: string; + }, + ]; + }; +}; + +type BetterTTVResponse = { + id: string; + code: string; + imageType: string; + animated: string; +}; + @Injectable() export class DiscordService { private logger = new Logger(DiscordService.name); @@ -325,4 +351,44 @@ export class DiscordService { } return this.database.addDiscordMessage({ name, content, lastEditedBy: author }); } + + async createEmote(name: string, emote: string | Buffer, guildId: string | null) { + if (!guildId) { + return; + } + + return this.discord.createEmote(name, emote, guildId); + } + + async create7TvEmote(id: string, guildId: string | null) { + if (!guildId) { + return; + } + + const rawResponse = await fetch(`https://7tv.io/v3/emotes/${id}`); + if (rawResponse.status !== 200) { + return; + } + + const response = (await rawResponse.json()) as SevenTVResponse; + const gif = response.host.files.findLast((file) => file.format === 'GIF' && file.size < 256_000); + const file = gif || response.host.files.findLast((file) => file.format === 'WEBP' && file.size < 256_000)!; + this.logger.log(`https:${response.host.url}/${file.name}`); + return this.discord.createEmote(response.name, `https:${response.host.url}/${file.name}`, guildId); + } + + async createBttvEmote(id: string, guildId: string | null) { + if (!guildId) { + return; + } + + const rawResponse = await fetch(`https://api.betterttv.net/3/emotes/${id}`); + if (rawResponse.status !== 200) { + return; + } + + const response = (await rawResponse.json()) as BetterTTVResponse; + + return this.discord.createEmote(response.code, `https://cdn.betterttv.net/emote/${id}/3x`, guildId); + } }