From 34354a93a7d106901953c2103ec7b1d4567bbe35 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 02:45:50 +0700 Subject: [PATCH] refactor: extract bot command catalog as single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/bot/commands/index.js owns the canonical catalog (name, description, adminOnly, build factory). bot.js builds dispatch from it; future menu registration reads it. Drops the 14 explicit factory imports + the inline /info handler from bot.js. Prevents the dispatch-vs-menu drift that bit us with /setdayswarning (commit 0131206 → 49726f1 backfill). --- README.md | 11 +++++---- src/bot/bot.js | 42 ++++--------------------------- src/bot/commands/index.js | 52 +++++++++++++++++++++++++++++++++++++++ src/bot/commands/info.js | 10 ++++++++ 4 files changed, 73 insertions(+), 42 deletions(-) create mode 100644 src/bot/commands/index.js create mode 100644 src/bot/commands/info.js diff --git a/README.md b/README.md index 0ad9d92..b7a9601 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,7 @@ Runs on Vercel serverless functions with Upstash Redis as the data store. - Upstash Redis schema mirrors the Java/Go Mongo layout: keys `admin`, `group:{chatId}`, `apple:{appId}`, `google:{appId}` (last two TTL'd via Redis `EX`). Multi-tenant isolation via `KEY_PREFIX` (default `store-scraper-bot:`). -- Telegram command identifiers match Java plus per-group settings: - `/info`, `/addgroup`, `/delgroup`, `/listgroup`, `/addapple`, `/delapple`, - `/addgoogle`, `/delgoogle`, `/listapp`, `/checkapp`, `/checkappscore`, - `/rawappleapp`, `/rawgoogleapp`, `/settings`, `/setdayswarning`. +- Command set defined in `src/bot/commands/index.js` (single source of truth — catalog drives both dispatch and the Telegram menu). Admin-only commands are hidden from the default menu and shown only in per-admin chat scope. - HTML parse mode; weekend-silent daily report; configurable upstream cache (default 10 min). - Per-group warning threshold override via `/setdayswarning` (falls back to `NUM_DAYS_WARNING_NOT_UPDATED` env default). - Inlined `app-store-scraper` + `google-play-scraper` (no external scraper service). @@ -58,7 +55,11 @@ Deploy: npm run deploy # vercel deploy --prod && register webhook ``` -`npm run register` re-points the Telegram webhook at the URL in `.env.deploy:WORKER_URL`. +`npm run register` re-points the Telegram webhook at the URL in `.env.deploy:WORKER_URL`, +and refreshes the menu: default scope = user commands only, plus a chat-scoped menu +(full set including admin commands) for every ID in `.env.deploy:ADMIN_IDS`. Re-run it +whenever `src/bot/commands/index.js` changes — Telegram caches the menu until +`setMyCommands` is called again. `npm run deploy` does this automatically. `npm run describe` updates the bot's profile description / about-text (run once when copy changes). ## Operations diff --git a/src/bot/bot.js b/src/bot/bot.js index 857840b..65fe057 100644 --- a/src/bot/bot.js +++ b/src/bot/bot.js @@ -1,18 +1,5 @@ import { createTelegramApi } from './telegram-api.js'; -import { createAddGroupCommand } from './commands/add-group.js'; -import { createDeleteGroupCommand } from './commands/delete-group.js'; -import { createListGroupCommand } from './commands/list-group.js'; -import { createAddAppleAppCommand } from './commands/add-apple-app.js'; -import { createDeleteAppleAppCommand } from './commands/delete-apple-app.js'; -import { createAddGoogleAppCommand } from './commands/add-google-app.js'; -import { createDeleteGoogleAppCommand } from './commands/delete-google-app.js'; -import { createListAppCommand } from './commands/list-app.js'; -import { createCheckAppCommand } from './commands/check-app.js'; -import { createCheckAppScoresCommand } from './commands/check-app-scores.js'; -import { createRawAppleAppCommand } from './commands/raw-apple-app.js'; -import { createRawGoogleAppCommand } from './commands/raw-google-app.js'; -import { createGetSettingsCommand } from './commands/get-settings.js'; -import { createSetDaysWarningCommand } from './commands/set-days-warning.js'; +import { COMMAND_CATALOG } from './commands/index.js'; const PARSE_MODE = 'HTML'; @@ -51,29 +38,10 @@ export function createBot(config, store, appleScraper, googleScraper) { }, }; - const commands = { - info: async (msg, sender, args) => { - if (args.length !== 0) { - await sender.sendMessage(msg.chat.id, 'Invalid arguments'); - return; - } - await sender.sendMessage(msg.chat.id, `Id của nhóm là ${msg.chat.id}\n`); - }, - addgroup: createAddGroupCommand(config, store), - delgroup: createDeleteGroupCommand(config, store), - listgroup: createListGroupCommand(config, store), - addapple: createAddAppleAppCommand(store, appleScraper), - delapple: createDeleteAppleAppCommand(store), - addgoogle: createAddGoogleAppCommand(store, googleScraper), - delgoogle: createDeleteGoogleAppCommand(store), - listapp: createListAppCommand(store), - checkapp: createCheckAppCommand(config, store, appleScraper, googleScraper), - checkappscore: createCheckAppScoresCommand(store, appleScraper, googleScraper), - rawappleapp: createRawAppleAppCommand(store, appleScraper), - rawgoogleapp: createRawGoogleAppCommand(store, googleScraper), - settings: createGetSettingsCommand(config, store), - setdayswarning: createSetDaysWarningCommand(config, store), - }; + const ctx = { config, store, appleScraper, googleScraper }; + const commands = Object.fromEntries( + COMMAND_CATALOG.map(({ name, build }) => [name, build(ctx)]), + ); return { sender, commands, api }; } diff --git a/src/bot/commands/index.js b/src/bot/commands/index.js new file mode 100644 index 0000000..835cace --- /dev/null +++ b/src/bot/commands/index.js @@ -0,0 +1,52 @@ +// Single source of truth for bot commands. +// Adding/removing/renaming a command here automatically updates dispatch (bot.js). +// Telegram menu only updates after `npm run register` (or `npm run deploy`). +// +// Catalog entry shape: +// { name, description, adminOnly, build(ctx) -> handler } +// `adminOnly: true` => hidden from the default Telegram menu, shown only in +// per-admin chat scope (see scripts/register-webhook.js). + +import { createInfoCommand } from './info.js'; +import { createAddGroupCommand } from './add-group.js'; +import { createDeleteGroupCommand } from './delete-group.js'; +import { createListGroupCommand } from './list-group.js'; +import { createAddAppleAppCommand } from './add-apple-app.js'; +import { createDeleteAppleAppCommand } from './delete-apple-app.js'; +import { createAddGoogleAppCommand } from './add-google-app.js'; +import { createDeleteGoogleAppCommand } from './delete-google-app.js'; +import { createListAppCommand } from './list-app.js'; +import { createCheckAppCommand } from './check-app.js'; +import { createCheckAppScoresCommand } from './check-app-scores.js'; +import { createRawAppleAppCommand } from './raw-apple-app.js'; +import { createRawGoogleAppCommand } from './raw-google-app.js'; +import { createGetSettingsCommand } from './get-settings.js'; +import { createSetDaysWarningCommand } from './set-days-warning.js'; + +export const COMMAND_CATALOG = [ + { name: 'info', description: 'Show this group ID', adminOnly: false, build: () => createInfoCommand() }, + { name: 'addgroup', description: '[admin] Authorize a group', adminOnly: true, build: (c) => createAddGroupCommand(c.config, c.store) }, + { name: 'delgroup', description: '[admin] Deauthorize a group', adminOnly: true, build: (c) => createDeleteGroupCommand(c.config, c.store) }, + { name: 'listgroup', description: '[admin] List authorized groups', adminOnly: true, build: (c) => createListGroupCommand(c.config, c.store) }, + { name: 'addapple', description: 'Track an Apple App Store app', adminOnly: false, build: (c) => createAddAppleAppCommand(c.store, c.appleScraper) }, + { name: 'delapple', description: 'Stop tracking an Apple app', adminOnly: false, build: (c) => createDeleteAppleAppCommand(c.store) }, + { name: 'addgoogle', description: 'Track a Google Play app', adminOnly: false, build: (c) => createAddGoogleAppCommand(c.store, c.googleScraper) }, + { name: 'delgoogle', description: 'Stop tracking a Google app', adminOnly: false, build: (c) => createDeleteGoogleAppCommand(c.store) }, + { name: 'listapp', description: 'List tracked apps in this group', adminOnly: false, build: (c) => createListAppCommand(c.store) }, + { name: 'checkapp', description: 'Check update status of tracked apps', adminOnly: false, build: (c) => createCheckAppCommand(c.config, c.store, c.appleScraper, c.googleScraper) }, + { name: 'checkappscore', description: 'Check scores + ratings of tracked apps', adminOnly: false, build: (c) => createCheckAppScoresCommand(c.store, c.appleScraper, c.googleScraper) }, + { name: 'rawappleapp', description: 'Dump raw Apple API JSON for an app', adminOnly: false, build: (c) => createRawAppleAppCommand(c.store, c.appleScraper) }, + { name: 'rawgoogleapp', description: 'Dump raw Google API JSON for an app', adminOnly: false, build: (c) => createRawGoogleAppCommand(c.store, c.googleScraper) }, + { name: 'settings', description: "Show this group's settings", adminOnly: false, build: (c) => createGetSettingsCommand(c.config, c.store) }, + { name: 'setdayswarning', description: 'Set warning threshold (days, 0 = default)', adminOnly: false, build: (c) => createSetDaysWarningCommand(c.config, c.store) }, +]; + +// Telegram menu projections. +// Default scope: user commands only — keeps admin entries out of every group's menu. +// Admin chat scope: full set including admin commands. +export const TELEGRAM_USER_COMMANDS = COMMAND_CATALOG + .filter((e) => !e.adminOnly) + .map(({ name, description }) => ({ command: name, description })); + +export const TELEGRAM_ADMIN_COMMANDS = COMMAND_CATALOG + .map(({ name, description }) => ({ command: name, description })); diff --git a/src/bot/commands/info.js b/src/bot/commands/info.js new file mode 100644 index 0000000..d2a9f3a --- /dev/null +++ b/src/bot/commands/info.js @@ -0,0 +1,10 @@ +// /info — replies with the current chat ID (used to obtain the group ID for /addgroup). +export function createInfoCommand() { + return async (msg, sender, args) => { + if (args.length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + await sender.sendMessage(msg.chat.id, `Id của nhóm là ${msg.chat.id}\n`); + }; +}