mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(ops): sync Telegram commands after deploy
This commit is contained in:
55
.github/workflows/cd.yml
vendored
55
.github/workflows/cd.yml
vendored
@@ -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<<EOF"
|
||||
echo "$token"
|
||||
echo "EOF"
|
||||
} >> "$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
|
||||
|
||||
@@ -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 <telegram_user_id> - Approve a pending member',
|
||||
'/anon <message> - Send anonymous household feedback in a private chat'
|
||||
].join('\n')
|
||||
)
|
||||
await ctx.reply(formatTelegramHelpText())
|
||||
})
|
||||
|
||||
bot.command('household_status', async (ctx) => {
|
||||
|
||||
89
apps/bot/src/telegram-commands.ts
Normal file
89
apps/bot/src/telegram-commands.ts
Normal 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')
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
164
scripts/ops/telegram-commands.ts
Normal file
164
scripts/ops/telegram-commands.ts
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user