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: "