import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { buildSchema, graphql } from "graphql"; import { parseTelegramBusinessUpdate } from "./telegram"; import { enqueueTelegramInboundTask, enqueueTelegramOutboundTask } from "./hatchet/tasks"; const PORT = Number(process.env.PORT || 8080); const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024); const WEBHOOK_SECRET = String(process.env.TELEGRAM_WEBHOOK_SECRET || "").trim(); const GRAPHQL_SHARED_SECRET = String(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET || "").trim(); const schema = buildSchema(` type Query { health: Health! } type Health { ok: Boolean! service: String! now: String! } input TelegramOutboundTaskInput { omniMessageId: String! chatId: String! text: String! businessConnectionId: String } input TelegramSendMessageInput { chatId: String! text: String! businessConnectionId: String } type TaskEnqueueResult { ok: Boolean! message: String! runId: String } type TelegramSendResult { ok: Boolean! message: String! providerMessageId: String responseJson: String } type Mutation { enqueueTelegramOutbound(input: TelegramOutboundTaskInput!): TaskEnqueueResult! sendTelegramMessage(input: TelegramSendMessageInput!): TelegramSendResult! } `); function asString(value: unknown) { if (typeof value !== "string") return null; const v = value.trim(); return v || null; } function writeJson(res: ServerResponse, statusCode: number, body: unknown) { res.statusCode = statusCode; res.setHeader("content-type", "application/json; charset=utf-8"); res.end(JSON.stringify(body)); } function isWebhookAuthorized(req: IncomingMessage) { if (!WEBHOOK_SECRET) return true; const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim(); return incoming !== "" && incoming === WEBHOOK_SECRET; } function isGraphqlAuthorized(req: IncomingMessage) { if (!GRAPHQL_SHARED_SECRET) return true; const incoming = String(req.headers["x-graphql-secret"] || "").trim(); return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET; } async function readJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let total = 0; for await (const chunk of req) { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); total += buf.length; if (total > MAX_BODY_SIZE_BYTES) { throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`); } chunks.push(buf); } const raw = Buffer.concat(chunks).toString("utf8"); if (!raw) return {}; return JSON.parse(raw); } async function sendTelegramMessage(input: { chatId: string; text: string; businessConnectionId?: string | null; }) { const token = asString(process.env.TELEGRAM_BOT_TOKEN); if (!token) { throw new Error("TELEGRAM_BOT_TOKEN is required"); } const base = String(process.env.TELEGRAM_API_BASE || "https://api.telegram.org").replace(/\/+$/, ""); const endpoint = `${base}/bot${token}/sendMessage`; const payload: Record = { chat_id: input.chatId, text: input.text, }; if (input.businessConnectionId) { payload.business_connection_id = input.businessConnectionId; } const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const text = await response.text(); const body = (() => { try { return JSON.parse(text) as Record; } catch { return { raw: text }; } })(); if (!response.ok || body?.ok === false) { throw new Error(`telegram send failed: ${response.status} ${JSON.stringify(body)}`); } const providerMessageId = body?.result?.message_id != null ? String(body.result.message_id) : body?.message_id != null ? String(body.message_id) : null; return { ok: true, message: "sent", providerMessageId, responseJson: JSON.stringify(body), }; } const root = { health: () => ({ ok: true, service: "telegram_backend", now: new Date().toISOString(), }), enqueueTelegramOutbound: async ({ input }: { input: any }) => { const run = await enqueueTelegramOutboundTask({ omniMessageId: String(input.omniMessageId ?? ""), chatId: String(input.chatId ?? ""), text: String(input.text ?? ""), businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null, }); return { ok: true, message: "enqueued", runId: run.runId, }; }, sendTelegramMessage: async ({ input }: { input: any }) => { const result = await sendTelegramMessage({ chatId: String(input.chatId ?? ""), text: String(input.text ?? ""), businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null, }); return result; }, }; export function startServer() { const server = createServer(async (req, res) => { if (!req.url || !req.method) { writeJson(res, 404, { ok: false, error: "not_found" }); return; } if (req.url === "/health" && req.method === "GET") { writeJson(res, 200, { ok: true, service: "telegram_backend", now: new Date().toISOString(), }); return; } if (req.url === "/webhooks/telegram/business" && req.method === "POST") { if (!isWebhookAuthorized(req)) { writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" }); return; } try { const body = await readJsonBody(req); const envelope = parseTelegramBusinessUpdate(body); const run = await enqueueTelegramInboundTask(envelope); writeJson(res, 200, { ok: true, queued: true, runId: run.runId, providerEventId: envelope.providerEventId, idempotencyKey: envelope.idempotencyKey, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const statusCode = message.startsWith("payload_too_large:") ? 413 : 503; writeJson(res, statusCode, { ok: false, error: "receiver_enqueue_failed", message, }); } return; } if (req.url === "/graphql" && req.method === "POST") { if (!isGraphqlAuthorized(req)) { writeJson(res, 401, { errors: [{ message: "unauthorized" }] }); return; } try { const body = (await readJsonBody(req)) as { query?: string; variables?: Record; operationName?: string; }; const result = await graphql({ schema, source: String(body.query || ""), rootValue: root, variableValues: body.variables || {}, operationName: body.operationName, }); writeJson(res, 200, result); } catch (error) { const message = error instanceof Error ? error.message : String(error); const statusCode = message.startsWith("payload_too_large:") ? 413 : 400; writeJson(res, statusCode, { errors: [{ message }] }); } return; } writeJson(res, 404, { ok: false, error: "not_found" }); }); server.listen(PORT, "0.0.0.0", () => { console.log(`[telegram_backend] listening on :${PORT}`); }); return server; }