diff --git a/.env.deploy.example b/.env.deploy.example index d4dab8d..4cae823 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -6,6 +6,10 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= +# Comma-separated Telegram user IDs (must match Vercel ADMIN_IDS). +# register-webhook.js sets a chat-scoped menu (with admin commands) per ID. +ADMIN_IDS= + # Where Telegram should send webhook updates. # Format: https:///api/webhook WORKER_URL=https://store-scraper-bot.vercel.app/api/webhook diff --git a/scripts/register-webhook.js b/scripts/register-webhook.js index f3507b8..8eecd6e 100644 --- a/scripts/register-webhook.js +++ b/scripts/register-webhook.js @@ -1,17 +1,28 @@ #!/usr/bin/env node -// Post-deploy registration: setWebhook (with secret_token) + setMyCommands. -// Run via: npm run register (reads .env.deploy) +// Post-deploy registration: +// - setWebhook (with secret_token) +// - setMyCommands at default scope (user-only commands) +// - setMyCommands at chat scope per ADMIN_ID (full set, including admin commands) +// Run via: npm run register (reads .env.deploy) // Dry run via: npm run register:dry +import { + TELEGRAM_USER_COMMANDS, + TELEGRAM_ADMIN_COMMANDS, +} from '../src/bot/commands/index.js'; +import { parseAdminIds } from '../src/util/parse-admin-ids.js'; + const TOKEN = process.env.TELEGRAM_BOT_TOKEN; const SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; const URL_ = process.env.WORKER_URL; +const ADMIN_IDS_RAW = process.env.ADMIN_IDS; const DRY = process.argv.includes('--dry-run'); for (const [k, v] of Object.entries({ TELEGRAM_BOT_TOKEN: TOKEN, TELEGRAM_WEBHOOK_SECRET: SECRET, WORKER_URL: URL_, + ADMIN_IDS: ADMIN_IDS_RAW, })) { if (!v) { console.error(`${k} is required`); @@ -19,25 +30,13 @@ for (const [k, v] of Object.entries({ } } -const COMMANDS = [ - { command: 'info', description: 'Show this group ID' }, - { command: 'addgroup', description: '[admin] Authorize a group' }, - { command: 'delgroup', description: '[admin] Deauthorize a group' }, - { command: 'listgroup', description: '[admin] List authorized groups' }, - { command: 'addapple', description: 'Track an Apple App Store app' }, - { command: 'delapple', description: 'Stop tracking an Apple app' }, - { command: 'addgoogle', description: 'Track a Google Play app' }, - { command: 'delgoogle', description: 'Stop tracking a Google app' }, - { command: 'listapp', description: 'List tracked apps in this group' }, - { command: 'checkapp', description: 'Check update status of tracked apps' }, - { command: 'checkappscore', description: 'Check scores + ratings of tracked apps' }, - { command: 'rawappleapp', description: 'Dump raw Apple API JSON for an app' }, - { command: 'rawgoogleapp', description: 'Dump raw Google API JSON for an app' }, - { command: 'settings', description: "Show this group's settings" }, - { command: 'setdayswarning', description: 'Set warning threshold (days, 0 = default)' }, -]; +const adminIds = parseAdminIds(ADMIN_IDS_RAW); +if (adminIds.length === 0) { + console.error('ADMIN_IDS must contain at least one numeric Telegram user ID'); + process.exit(1); +} -async function tg(method, payload) { +async function tg(method, payload, { allowFail = false } = {}) { if (DRY) { console.log(`[dry-run] ${method}`, JSON.stringify(payload, null, 2)); return { ok: true, result: '(dry)' }; @@ -49,6 +48,7 @@ async function tg(method, payload) { }); const body = await res.json(); if (!body.ok) { + if (allowFail) return body; console.error(`${method} failed`, body); process.exit(1); } @@ -60,6 +60,30 @@ await tg('setWebhook', { secret_token: SECRET, allowed_updates: ['message'], }); -await tg('setMyCommands', { commands: COMMANDS }); + +// Default scope: every chat sees these unless a more specific scope overrides. +await tg('setMyCommands', { commands: TELEGRAM_USER_COMMANDS }); + +// Per-admin chat scope: full set including admin commands. +// Telegram requires the admin has DMed the bot at least once for `chat` scope +// to resolve. If they haven't, the call returns "chat not found" — log + skip +// rather than aborting the whole register flow. +for (const adminId of adminIds) { + const result = await tg( + 'setMyCommands', + { + commands: TELEGRAM_ADMIN_COMMANDS, + scope: { type: 'chat', chat_id: adminId }, + }, + { allowFail: true }, + ); + if (!result.ok) { + console.warn( + `[warn] setMyCommands for admin ${adminId} failed: ${result.description}. ` + + `Admin must DM the bot at least once for chat-scoped menu.`, + ); + } +} + const info = await tg('getWebhookInfo', {}); console.log('Webhook state:', JSON.stringify(info.result, null, 2)); diff --git a/src/config.js b/src/config.js index 36f8856..7f5d700 100644 --- a/src/config.js +++ b/src/config.js @@ -1,13 +1,5 @@ import { createLogger } from './logger.js'; - -function parseAdminIds(raw) { - return raw - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0) - .map((s) => Number.parseInt(s, 10)) - .filter((n) => Number.isFinite(n)); -} +import { parseAdminIds } from './util/parse-admin-ids.js'; // Builds config from a plain env dictionary (Vercel's `process.env` or any // dict-like). Called once per webhook / cron invocation; cheap. diff --git a/src/util/parse-admin-ids.js b/src/util/parse-admin-ids.js new file mode 100644 index 0000000..8630b0c --- /dev/null +++ b/src/util/parse-admin-ids.js @@ -0,0 +1,10 @@ +// Parses ADMIN_IDS env (comma-separated Telegram user IDs). +// Shared between config loading and the register-webhook script. +export function parseAdminIds(raw) { + return (raw ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => Number.parseInt(s, 10)) + .filter((n) => Number.isFinite(n)); +}