Skip to content

Commit 2a89ddb

Browse files
sragssclaude
andauthored
feat(discord): auto-resolve guild emoji names to name:id format (#3)
When reacting with a plain emoji name like 'fatbiden', resolve it to 'fatbiden:1471188095478137007' by looking up guild emojis. Guild emoji list is cached for 5 minutes per guild. Existing behavior unchanged for unicode emoji, name:id format, and full <:name:id> syntax. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a617cd7 commit 2a89ddb

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

src/discord/guild-emoji-cache.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { RequestClient } from "@buape/carbon";
2+
import { Routes } from "discord-api-types/v10";
3+
4+
interface GuildEmoji {
5+
id: string;
6+
name: string;
7+
}
8+
9+
interface CacheEntry {
10+
emojis: GuildEmoji[];
11+
fetchedAt: number;
12+
}
13+
14+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
15+
16+
const guildEmojiCache = new Map<string, CacheEntry>();
17+
18+
async function getGuildEmojis(rest: RequestClient, guildId: string): Promise<GuildEmoji[]> {
19+
const cached = guildEmojiCache.get(guildId);
20+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
21+
return cached.emojis;
22+
}
23+
const raw = (await rest.get(Routes.guildEmojis(guildId))) as Array<{
24+
id?: string;
25+
name?: string;
26+
}>;
27+
const emojis = (raw ?? [])
28+
.filter((e): e is { id: string; name: string } => Boolean(e.id && e.name))
29+
.map((e) => ({ id: e.id, name: e.name }));
30+
guildEmojiCache.set(guildId, { emojis, fetchedAt: Date.now() });
31+
return emojis;
32+
}
33+
34+
function isPlainEmojiName(raw: string): boolean {
35+
return /^[a-zA-Z0-9_]+$/.test(raw);
36+
}
37+
38+
async function getGuildIdFromChannel(
39+
rest: RequestClient,
40+
channelId: string,
41+
): Promise<string | null> {
42+
try {
43+
const channel = (await rest.get(Routes.channel(channelId))) as { guild_id?: string };
44+
return channel.guild_id ?? null;
45+
} catch {
46+
return null;
47+
}
48+
}
49+
50+
/**
51+
* Resolve a plain emoji name to `name:id` format using guild emoji lookup.
52+
* Returns the original string unchanged for unicode emoji, name:id, or <:name:id> formats.
53+
*/
54+
export async function resolveGuildEmoji(
55+
rest: RequestClient,
56+
channelId: string,
57+
emoji: string,
58+
): Promise<string> {
59+
const trimmed = emoji.trim();
60+
if (!isPlainEmojiName(trimmed)) {
61+
return emoji;
62+
}
63+
64+
const guildId = await getGuildIdFromChannel(rest, channelId);
65+
if (!guildId) {
66+
return emoji;
67+
}
68+
69+
const emojis = await getGuildEmojis(rest, guildId);
70+
const match = emojis.find((e) => e.name === trimmed);
71+
if (!match) {
72+
return emoji;
73+
}
74+
75+
return `${match.name}:${match.id}`;
76+
}

src/discord/send.reactions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Routes } from "discord-api-types/v10";
22
import { loadConfig } from "../config/config.js";
3+
import { resolveGuildEmoji } from "./guild-emoji-cache.js";
34
import {
45
buildReactionIdentifier,
56
createDiscordClient,
@@ -16,7 +17,8 @@ export async function reactMessageDiscord(
1617
) {
1718
const cfg = opts.cfg ?? loadConfig();
1819
const { rest, request } = createDiscordClient(opts, cfg);
19-
const encoded = normalizeReactionEmoji(emoji);
20+
const resolved = await resolveGuildEmoji(rest, channelId, emoji);
21+
const encoded = normalizeReactionEmoji(resolved);
2022
await request(
2123
() => rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encoded)),
2224
"react",

src/discord/send.sends-basic-channel-messages.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,37 @@ describe("reactMessageDiscord", () => {
385385
Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
386386
);
387387
});
388+
389+
it("resolves plain emoji name via guild lookup", async () => {
390+
const { rest, putMock, getMock } = makeDiscordRest();
391+
getMock.mockResolvedValueOnce({ guild_id: "guild1" });
392+
getMock.mockResolvedValueOnce([
393+
{ id: "999", name: "fatbiden" },
394+
{ id: "888", name: "stonks" },
395+
]);
396+
await reactMessageDiscord("chan1", "msg1", "fatbiden", { rest, token: "t" });
397+
expect(putMock).toHaveBeenCalledWith(
398+
Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden%3A999"),
399+
);
400+
});
401+
402+
it("passes through plain name when guild lookup fails", async () => {
403+
const { rest, putMock, getMock } = makeDiscordRest();
404+
getMock.mockRejectedValueOnce(new Error("not found"));
405+
await reactMessageDiscord("chan1", "msg1", "fatbiden", { rest, token: "t" });
406+
expect(putMock).toHaveBeenCalledWith(
407+
Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden"),
408+
);
409+
});
410+
411+
it("passes through name:id format without extra lookup", async () => {
412+
const { rest, putMock, getMock } = makeDiscordRest();
413+
await reactMessageDiscord("chan1", "msg1", "fatbiden:999", { rest, token: "t" });
414+
expect(getMock).not.toHaveBeenCalled();
415+
expect(putMock).toHaveBeenCalledWith(
416+
Routes.channelMessageOwnReaction("chan1", "msg1", "fatbiden%3A999"),
417+
);
418+
});
388419
});
389420

390421
describe("removeReactionDiscord", () => {

0 commit comments

Comments
 (0)