feat(ops): sync Telegram commands after deploy

This commit is contained in:
2026-03-09 05:15:44 +04:00
parent 4e200b506a
commit 4d3c206f5f
5 changed files with 303 additions and 25 deletions

View File

@@ -75,22 +75,20 @@ jobs:
with: with:
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} 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 - name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2 uses: google-github-actions/auth@v2
with: with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} 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 - name: Run database migrations
if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }}
env: env:
@@ -100,6 +98,31 @@ jobs:
- name: Setup gcloud - name: Setup gcloud
uses: google-github-actions/setup-gcloud@v2 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<<EOF"
echo "$token"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Configure Artifact Registry auth - name: Configure Artifact Registry auth
run: | run: |
gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet
@@ -141,6 +164,18 @@ jobs:
--allow-unauthenticated \ --allow-unauthenticated \
--quiet --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: deploy-skipped:
name: Deploy skipped (missing config) name: Deploy skipped (missing config)
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,24 +1,13 @@
import { Bot } from 'grammy' import { Bot } from 'grammy'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import { formatTelegramHelpText } from './telegram-commands'
export function createTelegramBot(token: string, logger?: Logger): Bot { export function createTelegramBot(token: string, logger?: Logger): Bot {
const bot = new Bot(token) const bot = new Bot(token)
bot.command('help', async (ctx) => { bot.command('help', async (ctx) => {
await ctx.reply( await ctx.reply(formatTelegramHelpText())
[
'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 <telegram_user_id> - Approve a pending member',
'/anon <message> - Send anonymous household feedback in a private chat'
].join('\n')
)
}) })
bot.command('household_status', async (ctx) => { bot.command('household_status', async (ctx) => {

View File

@@ -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')
}

View File

@@ -34,7 +34,8 @@
"docker:smoke": "docker compose up --build", "docker:smoke": "docker compose up --build",
"test:e2e": "bun run scripts/e2e/billing-flow.ts", "test:e2e": "bun run scripts/e2e/billing-flow.ts",
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.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": { "devDependencies": {
"@types/bun": "1.3.10", "@types/bun": "1.3.10",

View File

@@ -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<T>(
botToken: string,
method: string,
body?: URLSearchParams
): Promise<T> {
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<void> {
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<void> {
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<void> {
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<void> {
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
})