diff --git a/src/discord/guild-emoji-cache.ts b/src/discord/guild-emoji-cache.ts new file mode 100644 index 0000000000000..dd9432d4d324a --- /dev/null +++ b/src/discord/guild-emoji-cache.ts @@ -0,0 +1,76 @@ +import { RequestClient } from "@buape/carbon"; +import { Routes } from "discord-api-types/v10"; + +interface GuildEmoji { + id: string; + name: string; +} + +interface CacheEntry { + emojis: GuildEmoji[]; + fetchedAt: number; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const guildEmojiCache = new Map(); + +async function getGuildEmojis(rest: RequestClient, guildId: string): Promise { + const cached = guildEmojiCache.get(guildId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.emojis; + } + const raw = (await rest.get(Routes.guildEmojis(guildId))) as Array<{ + id?: string; + name?: string; + }>; + const emojis = (raw ?? []) + .filter((e): e is { id: string; name: string } => Boolean(e.id && e.name)) + .map((e) => ({ id: e.id, name: e.name })); + guildEmojiCache.set(guildId, { emojis, fetchedAt: Date.now() }); + return emojis; +} + +function isPlainEmojiName(raw: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(raw); +} + +async function getGuildIdFromChannel( + rest: RequestClient, + channelId: string, +): Promise { + try { + const channel = (await rest.get(Routes.channel(channelId))) as { guild_id?: string }; + return channel.guild_id ?? null; + } catch { + return null; + } +} + +/** + * Resolve a plain emoji name to `name:id` format using guild emoji lookup. + * Returns the original string unchanged for unicode emoji, name:id, or <:name:id> formats. + */ +export async function resolveGuildEmoji( + rest: RequestClient, + channelId: string, + emoji: string, +): Promise { + const trimmed = emoji.trim(); + if (!isPlainEmojiName(trimmed)) { + return emoji; + } + + const guildId = await getGuildIdFromChannel(rest, channelId); + if (!guildId) { + return emoji; + } + + const emojis = await getGuildEmojis(rest, guildId); + const match = emojis.find((e) => e.name === trimmed); + if (!match) { + return emoji; + } + + return `${match.name}:${match.id}`; +} diff --git a/src/discord/send.reactions.ts b/src/discord/send.reactions.ts index 436d64ac5b2ec..71441fcb6a5e4 100644 --- a/src/discord/send.reactions.ts +++ b/src/discord/send.reactions.ts @@ -1,5 +1,6 @@ import { Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; +import { resolveGuildEmoji } from "./guild-emoji-cache.js"; import { buildReactionIdentifier, createDiscordClient, @@ -16,7 +17,8 @@ export async function reactMessageDiscord( ) { const cfg = opts.cfg ?? loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const encoded = normalizeReactionEmoji(emoji); + const resolved = await resolveGuildEmoji(rest, channelId, emoji); + const encoded = normalizeReactionEmoji(resolved); await request( () => rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encoded)), "react", diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index 58b8e3799b7f2..400283dee99e0 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -385,6 +385,37 @@ describe("reactMessageDiscord", () => { Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"), ); }); + + it("resolves plain emoji name via guild lookup", async () => { + const { rest, putMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ guild_id: "guild1" }); + getMock.mockResolvedValueOnce([ + { id: "999", name: "fatbiden" }, + { id: "888", name: "stonks" }, + ]); + await reactMessageDiscord("chan1", "msg1", "fatbiden", { rest, token: "t" }); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden%3A999"), + ); + }); + + it("passes through plain name when guild lookup fails", async () => { + const { rest, putMock, getMock } = makeDiscordRest(); + getMock.mockRejectedValueOnce(new Error("not found")); + await reactMessageDiscord("chan1", "msg1", "fatbiden", { rest, token: "t" }); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden"), + ); + }); + + it("passes through name:id format without extra lookup", async () => { + const { rest, putMock, getMock } = makeDiscordRest(); + await reactMessageDiscord("chan1", "msg1", "fatbiden:999", { rest, token: "t" }); + expect(getMock).not.toHaveBeenCalled(); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden%3A999"), + ); + }); }); describe("removeReactionDiscord", () => {