mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(WHE-21): scaffold grammy webhook bot server
This commit is contained in:
@@ -13,6 +13,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
|
|||||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
||||||
TELEGRAM_BOT_USERNAME=your_bot_username
|
TELEGRAM_BOT_USERNAME=your_bot_username
|
||||||
|
TELEGRAM_WEBHOOK_PATH=/webhook/telegram
|
||||||
|
|
||||||
# Parsing / AI
|
# Parsing / AI
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
|
|||||||
@@ -8,5 +8,8 @@
|
|||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
"test": "bun test --pass-with-no-tests",
|
"test": "bun test --pass-with-no-tests",
|
||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"grammy": "1.41.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/bot/src/bot.ts
Normal file
26
apps/bot/src/bot.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Bot } from 'grammy'
|
||||||
|
|
||||||
|
export function createTelegramBot(token: string): 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'
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
bot.command('household_status', async (ctx) => {
|
||||||
|
await ctx.reply('Household status is not connected yet. Data integration is next.')
|
||||||
|
})
|
||||||
|
|
||||||
|
bot.catch((error) => {
|
||||||
|
console.error('Telegram bot error', error.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return bot
|
||||||
|
}
|
||||||
36
apps/bot/src/config.ts
Normal file
36
apps/bot/src/config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface BotRuntimeConfig {
|
||||||
|
port: number
|
||||||
|
telegramBotToken: string
|
||||||
|
telegramWebhookSecret: string
|
||||||
|
telegramWebhookPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(raw: string | undefined): number {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(raw)
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||||
|
throw new Error(`Invalid PORT value: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireValue(value: string | undefined, key: string): string {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new Error(`${key} environment variable is required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||||
|
return {
|
||||||
|
port: parsePort(env.PORT),
|
||||||
|
telegramBotToken: requireValue(env.TELEGRAM_BOT_TOKEN, 'TELEGRAM_BOT_TOKEN'),
|
||||||
|
telegramWebhookSecret: requireValue(env.TELEGRAM_WEBHOOK_SECRET, 'TELEGRAM_WEBHOOK_SECRET'),
|
||||||
|
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
const startupMessage = '@household/bot scaffold is ready'
|
import { webhookCallback } from 'grammy'
|
||||||
|
|
||||||
|
import { createTelegramBot } from './bot'
|
||||||
|
import { getBotRuntimeConfig } from './config'
|
||||||
|
import { createBotWebhookServer } from './server'
|
||||||
|
|
||||||
|
const runtime = getBotRuntimeConfig()
|
||||||
|
const bot = createTelegramBot(runtime.telegramBotToken)
|
||||||
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
|
|
||||||
|
const server = createBotWebhookServer({
|
||||||
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
|
webhookHandler
|
||||||
|
})
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
console.log(startupMessage)
|
Bun.serve({
|
||||||
|
port: runtime.port,
|
||||||
|
fetch: server.fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`@household/bot webhook server started on :${runtime.port} path=${runtime.telegramWebhookPath}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { startupMessage }
|
export { server }
|
||||||
|
|||||||
62
apps/bot/src/server.test.ts
Normal file
62
apps/bot/src/server.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { createBotWebhookServer } from './server'
|
||||||
|
|
||||||
|
describe('createBotWebhookServer', () => {
|
||||||
|
const server = createBotWebhookServer({
|
||||||
|
webhookPath: '/webhook/telegram',
|
||||||
|
webhookSecret: 'secret-token',
|
||||||
|
webhookHandler: async () => new Response('ok', { status: 200 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns health payload', async () => {
|
||||||
|
const response = await server.fetch(new Request('http://localhost/healthz'))
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unknown path', async () => {
|
||||||
|
const response = await server.fetch(new Request('http://localhost/unknown'))
|
||||||
|
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects webhook request with missing secret', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/webhook/telegram', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects non-post method for webhook endpoint', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/webhook/telegram', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'x-telegram-bot-api-secret-token': 'secret-token'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(405)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts authorized webhook request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/webhook/telegram', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-telegram-bot-api-secret-token': 'secret-token'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ update_id: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.text()).toBe('ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
52
apps/bot/src/server.ts
Normal file
52
apps/bot/src/server.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export interface BotWebhookServerOptions {
|
||||||
|
webhookPath: string
|
||||||
|
webhookSecret: string
|
||||||
|
webhookHandler: (request: Request) => Promise<Response> | Response
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: object, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(request: Request, expectedSecret: string): boolean {
|
||||||
|
const secretHeader = request.headers.get('x-telegram-bot-api-secret-token')
|
||||||
|
|
||||||
|
return secretHeader === expectedSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||||
|
fetch: (request: Request) => Promise<Response>
|
||||||
|
} {
|
||||||
|
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||||
|
? options.webhookPath
|
||||||
|
: `/${options.webhookPath}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetch: async (request: Request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
if (url.pathname === '/healthz') {
|
||||||
|
return json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname !== normalizedWebhookPath) {
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return new Response('Method Not Allowed', { status: 405 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthorized(request, options.webhookSecret)) {
|
||||||
|
return new Response('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return await options.webhookHandler(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
bun.lock
19
bun.lock
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"apps/bot": {
|
"apps/bot": {
|
||||||
"name": "@household/bot",
|
"name": "@household/bot",
|
||||||
|
"dependencies": {
|
||||||
|
"grammy": "1.41.1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"apps/miniapp": {
|
"apps/miniapp": {
|
||||||
"name": "@household/miniapp",
|
"name": "@household/miniapp",
|
||||||
@@ -160,6 +163,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
||||||
|
|
||||||
"@household/application": ["@household/application@workspace:packages/application"],
|
"@household/application": ["@household/application@workspace:packages/application"],
|
||||||
|
|
||||||
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
||||||
@@ -366,6 +371,8 @@
|
|||||||
|
|
||||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260304.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lg/w+rZ9NIUoqSsk2TbtDsqyD9nW0/rhTMYd14RFP7vuNijLrTbl7GPiMhFtMxaqCSOFapwbql7/3lU4BKHB6g=="],
|
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260304.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lg/w+rZ9NIUoqSsk2TbtDsqyD9nW0/rhTMYd14RFP7vuNijLrTbl7GPiMhFtMxaqCSOFapwbql7/3lU4BKHB6g=="],
|
||||||
|
|
||||||
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
||||||
|
|
||||||
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||||
@@ -404,6 +411,8 @@
|
|||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -414,6 +423,8 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="],
|
||||||
|
|
||||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||||
|
|
||||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||||
@@ -460,6 +471,8 @@
|
|||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||||
|
|
||||||
"oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="],
|
"oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="],
|
||||||
@@ -502,6 +515,8 @@
|
|||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
@@ -514,6 +529,10 @@
|
|||||||
|
|
||||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|||||||
63
docs/specs/HOUSEBOT-020-grammy-webhook-bot-app.md
Normal file
63
docs/specs/HOUSEBOT-020-grammy-webhook-bot-app.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# HOUSEBOT-020: grammY Webhook Bot Scaffold
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Build a Cloud Run-compatible webhook server for Telegram bot updates with command routing stubs.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Expose `/healthz` endpoint.
|
||||||
|
- Expose webhook endpoint with secret header validation.
|
||||||
|
- Register basic command stubs for `/help` and `/household_status`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Purchase ingestion logic.
|
||||||
|
- Billing command business logic.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In: bot runtime config, webhook server, command stubs, endpoint tests.
|
||||||
|
- Out: persistence integration and scheduler handlers.
|
||||||
|
|
||||||
|
## Interfaces and Contracts
|
||||||
|
|
||||||
|
- `GET /healthz` -> `{ "ok": true }`
|
||||||
|
- `POST /webhook/telegram` requires header:
|
||||||
|
- `x-telegram-bot-api-secret-token`
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- Reject unauthorized webhook calls (`401`).
|
||||||
|
- Reject non-POST webhook calls (`405`).
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Security and Privacy
|
||||||
|
|
||||||
|
- Validate Telegram secret token header before processing updates.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- Startup log includes bound port and webhook path.
|
||||||
|
|
||||||
|
## Edge Cases and Failure Modes
|
||||||
|
|
||||||
|
- Missing required bot env vars.
|
||||||
|
- Requests to unknown paths.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit/integration-like tests for endpoint auth and method handling.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Health endpoint exists.
|
||||||
|
- [ ] Webhook endpoint validates secret header.
|
||||||
|
- [ ] `/help` and `/household_status` command stubs exist.
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
- Deploy webhook service in dry mode first, then register Telegram webhook URL.
|
||||||
Reference in New Issue
Block a user