From 4d3c206f5ff2a63a313a7b6255c73b86bbd66020 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 05:15:44 +0400 Subject: [PATCH] feat(ops): sync Telegram commands after deploy --- .github/workflows/cd.yml | 55 ++++++++-- apps/bot/src/bot.ts | 17 +--- apps/bot/src/telegram-commands.ts | 89 ++++++++++++++++ package.json | 3 +- scripts/ops/telegram-commands.ts | 164 ++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 25 deletions(-) create mode 100644 apps/bot/src/telegram-commands.ts create mode 100644 scripts/ops/telegram-commands.ts diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6bbc3c4..4847ff3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -75,22 +75,20 @@ jobs: with: ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - name: Setup Bun - if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: .bun-version - - - name: Install dependencies for migrations - if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} - run: bun install --frozen-lockfile - - name: Run database migrations if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} env: @@ -100,6 +98,31 @@ jobs: - name: Setup gcloud uses: google-github-actions/setup-gcloud@v2 + - name: Load Telegram bot token for command sync + id: telegram-token + env: + TELEGRAM_BOT_TOKEN_SECRET_ID: ${{ vars.TELEGRAM_BOT_TOKEN_SECRET_ID || 'telegram-bot-token' }} + run: | + set +e + token="$(gcloud secrets versions access latest \ + --secret "${TELEGRAM_BOT_TOKEN_SECRET_ID}" \ + --project "${{ secrets.GCP_PROJECT_ID }}" 2>/dev/null)" + status=$? + set -e + + if [[ $status -ne 0 || -z "$token" ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "::add-mask::$token" + { + echo "available=true" + echo "token<> "$GITHUB_OUTPUT" + - name: Configure Artifact Registry auth run: | gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet @@ -141,6 +164,18 @@ jobs: --allow-unauthenticated \ --quiet + - name: Sync Telegram commands + if: ${{ steps.telegram-token.outputs.available == 'true' }} + env: + TELEGRAM_BOT_TOKEN: ${{ steps.telegram-token.outputs.token }} + run: bun run ops:telegram:commands set + + - name: Telegram command sync skipped + if: ${{ steps.telegram-token.outputs.available != 'true' }} + run: | + echo "Telegram command sync skipped." + echo "Grant the CD service account access to the bot token secret or set TELEGRAM_BOT_TOKEN_SECRET_ID." + deploy-skipped: name: Deploy skipped (missing config) runs-on: ubuntu-latest diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 9c54bc8..2b268ed 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,24 +1,13 @@ import { Bot } from 'grammy' import type { Logger } from '@household/observability' +import { formatTelegramHelpText } from './telegram-commands' + export function createTelegramBot(token: string, logger?: Logger): Bot { const bot = new Bot(token) bot.command('help', async (ctx) => { - await ctx.reply( - [ - 'Household bot scaffold is live.', - 'Available commands:', - '/help - Show command list', - '/household_status - Show placeholder household status', - '/setup [household name] - Register this group as a household', - '/bind_purchase_topic - Bind the current topic as the purchase topic', - '/bind_feedback_topic - Bind the current topic as the feedback topic', - '/pending_members - List pending household join requests', - '/approve_member - Approve a pending member', - '/anon - Send anonymous household feedback in a private chat' - ].join('\n') - ) + await ctx.reply(formatTelegramHelpText()) }) bot.command('household_status', async (ctx) => { diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts new file mode 100644 index 0000000..b638ef6 --- /dev/null +++ b/apps/bot/src/telegram-commands.ts @@ -0,0 +1,89 @@ +export interface TelegramCommandDefinition { + command: string + description: string +} + +export interface ScopedTelegramCommands { + scope: 'default' | 'all_private_chats' | 'all_group_chats' | 'all_chat_administrators' + commands: readonly TelegramCommandDefinition[] +} + +const DEFAULT_COMMANDS = [ + { + command: 'help', + description: 'Show command list' + }, + { + command: 'household_status', + description: 'Show current household status' + } +] as const satisfies readonly TelegramCommandDefinition[] + +const PRIVATE_CHAT_COMMANDS = [ + ...DEFAULT_COMMANDS, + { + command: 'anon', + description: 'Send anonymous household feedback' + }, + { + command: 'cancel', + description: 'Cancel the current prompt' + } +] as const satisfies readonly TelegramCommandDefinition[] + +const GROUP_CHAT_COMMANDS = DEFAULT_COMMANDS + +const GROUP_ADMIN_COMMANDS = [ + ...GROUP_CHAT_COMMANDS, + { + command: 'setup', + description: 'Register this group as a household' + }, + { + command: 'bind_purchase_topic', + description: 'Bind the current topic as purchases' + }, + { + command: 'bind_feedback_topic', + description: 'Bind the current topic as feedback' + }, + { + command: 'pending_members', + description: 'List pending household join requests' + }, + { + command: 'approve_member', + description: 'Approve a pending household member' + } +] as const satisfies readonly TelegramCommandDefinition[] + +export const TELEGRAM_COMMAND_SCOPES = [ + { + scope: 'default', + commands: DEFAULT_COMMANDS + }, + { + scope: 'all_private_chats', + commands: PRIVATE_CHAT_COMMANDS + }, + { + scope: 'all_group_chats', + commands: GROUP_CHAT_COMMANDS + }, + { + scope: 'all_chat_administrators', + commands: GROUP_ADMIN_COMMANDS + } +] as const satisfies readonly ScopedTelegramCommands[] + +export function formatTelegramHelpText(): string { + return [ + 'Household bot scaffold is live.', + 'Private chat:', + ...PRIVATE_CHAT_COMMANDS.map((command) => `/${command.command} - ${command.description}`), + 'Group admins:', + ...GROUP_ADMIN_COMMANDS.filter( + (command) => !DEFAULT_COMMANDS.some((baseCommand) => baseCommand.command === command.command) + ).map((command) => `/${command.command} - ${command.description}`) + ].join('\n') +} diff --git a/package.json b/package.json index 9d52fc5..eefe6cd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "docker:smoke": "docker compose up --build", "test:e2e": "bun run scripts/e2e/billing-flow.ts", "ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts", - "ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts" + "ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts", + "ops:telegram:commands": "bun run scripts/ops/telegram-commands.ts" }, "devDependencies": { "@types/bun": "1.3.10", diff --git a/scripts/ops/telegram-commands.ts b/scripts/ops/telegram-commands.ts new file mode 100644 index 0000000..fe7a4a7 --- /dev/null +++ b/scripts/ops/telegram-commands.ts @@ -0,0 +1,164 @@ +import { TELEGRAM_COMMAND_SCOPES } from '../../apps/bot/src/telegram-commands' + +type CommandsCommand = 'info' | 'set' | 'delete' + +interface TelegramScopePayload { + type: 'default' | 'all_private_chats' | 'all_group_chats' | 'all_chat_administrators' +} + +function requireEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`${name} is required`) + } + + return value +} + +function parseCommand(raw: string | undefined): CommandsCommand { + const command = raw?.trim() || 'info' + if (command === 'info' || command === 'set' || command === 'delete') { + return command + } + + throw new Error(`Unsupported command: ${command}`) +} + +async function telegramRequest( + botToken: string, + method: string, + body?: URLSearchParams +): Promise { + const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, { + method: body ? 'POST' : 'GET', + body + }) + + const payload = (await response.json()) as { + ok?: boolean + result?: unknown + } + + if (!response.ok || payload.ok !== true) { + throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`) + } + + return payload.result as T +} + +function appendScope(params: URLSearchParams, scope: TelegramScopePayload): void { + params.set('scope', JSON.stringify(scope)) +} + +async function setCommands(botToken: string): Promise { + const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() + + for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { + const params = new URLSearchParams({ + commands: JSON.stringify(scopeConfig.commands) + }) + + appendScope(params, { + type: scopeConfig.scope + }) + + if (languageCode) { + params.set('language_code', languageCode) + } + + await telegramRequest(botToken, 'setMyCommands', params) + } + + console.log( + JSON.stringify( + { + ok: true, + scopes: TELEGRAM_COMMAND_SCOPES.map((scope) => ({ + scope: scope.scope, + commandCount: scope.commands.length + })) + }, + null, + 2 + ) + ) +} + +async function deleteCommands(botToken: string): Promise { + const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() + + for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { + const params = new URLSearchParams() + appendScope(params, { + type: scopeConfig.scope + }) + + if (languageCode) { + params.set('language_code', languageCode) + } + + await telegramRequest(botToken, 'deleteMyCommands', params) + } + + console.log( + JSON.stringify( + { + ok: true, + deletedScopes: TELEGRAM_COMMAND_SCOPES.map((scope) => scope.scope) + }, + null, + 2 + ) + ) +} + +async function getCommands(botToken: string): Promise { + const languageCode = process.env.TELEGRAM_COMMANDS_LANGUAGE_CODE?.trim() + const result: Array<{ + scope: string + commands: unknown + }> = [] + + for (const scopeConfig of TELEGRAM_COMMAND_SCOPES) { + const params = new URLSearchParams() + appendScope(params, { + type: scopeConfig.scope + }) + + if (languageCode) { + params.set('language_code', languageCode) + } + + const commands = await telegramRequest(botToken, 'getMyCommands', params) + result.push({ + scope: scopeConfig.scope, + commands + }) + } + + console.log(JSON.stringify(result, null, 2)) +} + +async function run(): Promise { + const command = parseCommand(process.argv[2]) + const botToken = requireEnv('TELEGRAM_BOT_TOKEN') + + switch (command) { + case 'set': + await setCommands(botToken) + return + case 'delete': + await deleteCommands(botToken) + return + case 'info': + await getCommands(botToken) + return + default: + throw new Error(`Unsupported command: ${command}`) + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +})