diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md
index c21efda..bc8e0cd 100644
--- a/docs/codebase-summary.md
+++ b/docs/codebase-summary.md
@@ -19,7 +19,7 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
| Module | Status | Commands | Storage | Crons | Description |
|--------|--------|----------|---------|-------|-------------|
-| `util` | Complete | `/info`, `/help` | — | — | Bot info and command help renderer |
+| `util` | Complete | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, and sticker file_id echo helper |
| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
| `wordle` | Complete | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV (game, stats) | — | Classic 5-letter word game. 14,855-word dict sourced from [dracos's gist](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93). |
| `loldle` | Complete | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | — | Classic-mode LoL champion guesser (auto-starts a new round after solve/giveup). Champion data synced from `tiennm99/loldle-data`. |
diff --git a/src/modules/util/README.md b/src/modules/util/README.md
index 4f2b5b8..0ea13f9 100644
--- a/src/modules/util/README.md
+++ b/src/modules/util/README.md
@@ -8,11 +8,13 @@ Core bot utilities — informational commands that read framework metadata.
|---------|-----------|-------------|
| `/info` | public | Echoes chat id, thread id, sender id (debug helper) |
| `/help` | public | Renders all public + protected commands grouped by module |
+| `/stickerid` | private | Reply to a sticker to get its bot-scoped `file_id` (used to collect sticker pools for other modules) |
## Architecture
- `/help` is a pure renderer over `getCurrentRegistry()` — it reads the registry's command maps and formats them as Telegram HTML. Modules with zero visible commands are omitted. Private commands are always excluded.
- `/info` reads grammY context fields (`ctx.chat.id`, `ctx.message.message_thread_id`, `ctx.from.id`). No external state.
+- `/stickerid` reads `ctx.message.reply_to_message.sticker` and echoes `file_id` + `file_unique_id`. Private visibility keeps it out of `/help` and the Telegram menu. Used to collect sticker IDs for hard-coded pools in other modules (e.g., loldle win/lose/giveup stickers).
- HTML injection is prevented via `escapeHtml()` on all user-influenced strings.
## Database
diff --git a/src/modules/util/index.js b/src/modules/util/index.js
index 5cf0cbf..a437b34 100644
--- a/src/modules/util/index.js
+++ b/src/modules/util/index.js
@@ -8,11 +8,12 @@
import { helpCommand } from "./help-command.js";
import { infoCommand } from "./info-command.js";
+import { stickerIdCommand } from "./stickerid-command.js";
/** @type {import("../registry.js").BotModule} */
const utilModule = {
name: "util",
- commands: [infoCommand, helpCommand],
+ commands: [infoCommand, helpCommand, stickerIdCommand],
};
export default utilModule;
diff --git a/src/modules/util/stickerid-command.js b/src/modules/util/stickerid-command.js
new file mode 100644
index 0000000..7185c33
--- /dev/null
+++ b/src/modules/util/stickerid-command.js
@@ -0,0 +1,42 @@
+/**
+ * @file /stickerid — dev helper that returns the file_id of a replied sticker.
+ *
+ * Telegram sticker file_ids are bot-scoped: a file_id obtained from any other
+ * bot will not work with sendSticker for this bot. To collect IDs for use in
+ * `/loldle` congrats/lose/giveup pools, send a sticker to the bot, reply to
+ * it with `/stickerid`, then copy the returned file_id into code.
+ *
+ * Private visibility — hidden from the Telegram `/` menu and from `/help`.
+ */
+
+import { escapeHtml } from "../../util/escape-html.js";
+
+/** @type {import("../validate-command.js").ModuleCommand} */
+export const stickerIdCommand = {
+ name: "stickerid",
+ visibility: "private",
+ description: "Reply to a sticker with this command to get its bot-scoped file_id",
+ handler: async (ctx) => {
+ const sticker = ctx.message?.reply_to_message?.sticker;
+ if (!sticker) {
+ return ctx.reply(
+ "Reply to a sticker message with /stickerid to get its file_id.\n" +
+ "Usage: send a sticker to me, then tap Reply on it and type /stickerid.",
+ );
+ }
+
+ const setName = sticker.set_name ?? "(no set)";
+ const lines = [
+ "file_id",
+ `${escapeHtml(sticker.file_id)}`,
+ "",
+ "file_unique_id",
+ `${escapeHtml(sticker.file_unique_id)}`,
+ "",
+ `set: ${escapeHtml(setName)} · emoji: ${escapeHtml(sticker.emoji ?? "—")}`,
+ ];
+ await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
+ },
+};
+
+export default stickerIdCommand;
diff --git a/tests/modules/dispatcher.test.js b/tests/modules/dispatcher.test.js
index 9c09247..cb24210 100644
--- a/tests/modules/dispatcher.test.js
+++ b/tests/modules/dispatcher.test.js
@@ -24,9 +24,9 @@ describe("installDispatcher", () => {
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
- // Expect 12 total commands (10 public + 1 protected + 1 private).
- expect(bot.commandCalls).toHaveLength(12);
- expect(reg.allCommands.size).toBe(12);
+ // Expect 13 total commands (10 public + 1 protected + 2 private).
+ expect(bot.commandCalls).toHaveLength(13);
+ expect(reg.allCommands.size).toBe(13);
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
const expected = [...reg.allCommands.keys()].sort();
diff --git a/tests/modules/util/stickerid-command.test.js b/tests/modules/util/stickerid-command.test.js
new file mode 100644
index 0000000..0a54072
--- /dev/null
+++ b/tests/modules/util/stickerid-command.test.js
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+import { stickerIdCommand } from "../../../src/modules/util/stickerid-command.js";
+
+function makeCtx({ sticker }) {
+ const reply = vi.fn(async () => {});
+ return {
+ reply,
+ message: sticker ? { reply_to_message: { sticker } } : {},
+ };
+}
+
+describe("/stickerid", () => {
+ it("is a private command so it stays out of /help and the Telegram menu", () => {
+ expect(stickerIdCommand.visibility).toBe("private");
+ });
+
+ it("tells the user how to use it when no sticker is replied to", async () => {
+ const ctx = makeCtx({ sticker: null });
+ await stickerIdCommand.handler(ctx);
+ const [text] = ctx.reply.mock.calls[0];
+ expect(text).toMatch(/reply to a sticker/i);
+ });
+
+ it("echoes file_id + file_unique_id when replying to a sticker", async () => {
+ const ctx = makeCtx({
+ sticker: {
+ file_id: "CAACAgIAAxkBAAEFAKE_ID",
+ file_unique_id: "AgADFAKEUNIQ",
+ set_name: "HotCherry",
+ emoji: "🔥",
+ },
+ });
+ await stickerIdCommand.handler(ctx);
+ const [text, opts] = ctx.reply.mock.calls[0];
+ expect(opts).toEqual({ parse_mode: "HTML" });
+ expect(text).toContain("CAACAgIAAxkBAAEFAKE_ID");
+ expect(text).toContain("AgADFAKEUNIQ");
+ expect(text).toContain("HotCherry");
+ expect(text).toContain("🔥");
+ });
+
+ it("HTML-escapes sticker set name so a malicious set label can't inject tags", async () => {
+ const ctx = makeCtx({
+ sticker: {
+ file_id: "id1",
+ file_unique_id: "uid1",
+ set_name: "