refactor chat delivery to graphql + hatchet services

This commit is contained in:
Ruslan Bakiev
2026-03-08 18:55:58 +07:00
parent fe4bd59248
commit 7d1bed0d67
61 changed files with 5007 additions and 5004 deletions

View File

@@ -0,0 +1,270 @@
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<unknown> {
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<string, unknown> = {
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<string, any>;
} 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<string, unknown>;
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;
}