271 lines
7.4 KiB
TypeScript
271 lines
7.4 KiB
TypeScript
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;
|
|
}
|