refactor chat delivery to graphql + hatchet services
This commit is contained in:
270
telegram_backend/src/server.ts
Normal file
270
telegram_backend/src/server.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user