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`);
+ };
+}