feat(util): add private /stickerid to capture bot-scoped sticker file_ids

Reply to any sticker with /stickerid to get its file_id and
file_unique_id. Collects IDs for hard-coded sticker pools in other
modules (upcoming loldle win/lose/giveup reactions).

Private visibility keeps the command out of /help and the Telegram
slash menu.
This commit is contained in:
tiennm99 committed 2026-04-21 23:48:27 +07:00
1 parent 679bf8ec6f
commit 06a93c6c60
6 files changed
+106 -5

No files matched your search

+1 -1
View File
@@ -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`. |
+2
View File
@@ -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
+2 -1
View File
@@ -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;
+42
View File
@@ -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 = [
"<b>file_id</b>",
`<code>${escapeHtml(sticker.file_id)}</code>`,
"",
"<b>file_unique_id</b>",
`<code>${escapeHtml(sticker.file_unique_id)}</code>`,
"",
`set: ${escapeHtml(setName)} · emoji: ${escapeHtml(sticker.emoji ?? "—")}`,
];
await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
},
};
export default stickerIdCommand;
+3 -3
View File
@@ -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();
@@ -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: "<script>",
emoji: null,
},
});
await stickerIdCommand.handler(ctx);
const [text] = ctx.reply.mock.calls[0];
expect(text).toContain("&lt;script&gt;");
expect(text).not.toContain("<script>");
});
});