diff --git a/.env.example b/.env.example index 51e8f97..aa09cfe 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_WEBHOOK_SECRET=your-webhook-secret TELEGRAM_BOT_USERNAME=your_bot_username +TELEGRAM_WEBHOOK_PATH=/webhook/telegram # Parsing / AI OPENAI_API_KEY=your-openai-api-key diff --git a/apps/bot/package.json b/apps/bot/package.json index ab60d89..349dfff 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -8,5 +8,8 @@ "typecheck": "tsgo --project tsconfig.json --noEmit", "test": "bun test --pass-with-no-tests", "lint": "oxlint \"src\"" + }, + "dependencies": { + "grammy": "1.41.1" } } diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts new file mode 100644 index 0000000..5b10144 --- /dev/null +++ b/apps/bot/src/bot.ts @@ -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 +} diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts new file mode 100644 index 0000000..c01c832 --- /dev/null +++ b/apps/bot/src/config.ts @@ -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' + } +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index e47039f..1fa34d8 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -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) { - 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 } diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts new file mode 100644 index 0000000..ed771b3 --- /dev/null +++ b/apps/bot/src/server.test.ts @@ -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') + }) +}) diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts new file mode 100644 index 0000000..d9fc210 --- /dev/null +++ b/apps/bot/src/server.ts @@ -0,0 +1,52 @@ +export interface BotWebhookServerOptions { + webhookPath: string + webhookSecret: string + webhookHandler: (request: Request) => Promise | 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 +} { + 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) + } + } +} diff --git a/bun.lock b/bun.lock index 14c066b..64313d9 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,9 @@ }, "apps/bot": { "name": "@household/bot", + "dependencies": { + "grammy": "1.41.1", + }, }, "apps/miniapp": { "name": "@household/miniapp", @@ -160,6 +163,8 @@ "@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/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=="], + "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-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=="], + "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=="], "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=="], + "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=="], "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=="], + "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=="], "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=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "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=="], @@ -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=="], + "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=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/docs/specs/HOUSEBOT-020-grammy-webhook-bot-app.md b/docs/specs/HOUSEBOT-020-grammy-webhook-bot-app.md new file mode 100644 index 0000000..750cb1e --- /dev/null +++ b/docs/specs/HOUSEBOT-020-grammy-webhook-bot-app.md @@ -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.