feat: admin-scoped Telegram menu

setMyCommands now runs at default scope (user commands only) plus once
per ADMIN_ID with scope:{type:'chat'} carrying the full set, so admin
commands (/addgroup, /delgroup, /listgroup) are hidden from non-admins'
menu. requireAdminUser remains the actual auth gate; this is UI cleanup.

parseAdminIds extracted to src/util/parse-admin-ids.js — shared between
config loading and the register script. ADMIN_IDS now required by
register-webhook.js (.env.deploy.example updated). Per-admin call uses
allowFail to gracefully skip admins who haven't DMed the bot yet.
This commit is contained in:
tiennm99 committed 2026-05-10 02:46:04 +07:00
1 parent 34354a93a7
commit 1ed82578c0
4 files changed
+60 -30

No files matched your search

+4
View File
@@ -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://<your-vercel-url>/api/webhook
WORKER_URL=https://store-scraper-bot.vercel.app/api/webhook
+45 -21
View File
@@ -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));
+1 -9
View File
@@ -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.
+10
View File
@@ -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));
}