mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:14:02 +00:00
feat(WHE-21): scaffold grammy webhook bot server
This commit is contained in:
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user