Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/discord/guild-emoji-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, CacheEntry>();

async function getGuildEmojis(rest: RequestClient, guildId: string): Promise<GuildEmoji[]> {
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<string | null> {
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<string> {
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}`;
}
4 changes: 3 additions & 1 deletion src/discord/send.reactions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/discord/send.sends-basic-channel-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading