From 69cb463cbfece62cdbea0913524e2c0133e9169d Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 7 Jun 2026 19:28:00 +0800 Subject: [PATCH] fix: harden traffic telegram alert delivery and fallback chat id --- apps/web/src/lib/traffic-alert.ts | 182 +++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 41 deletions(-) diff --git a/apps/web/src/lib/traffic-alert.ts b/apps/web/src/lib/traffic-alert.ts index 4721054..dc3e4a1 100644 --- a/apps/web/src/lib/traffic-alert.ts +++ b/apps/web/src/lib/traffic-alert.ts @@ -1,7 +1,12 @@ -import { request } from "node:https"; import { prisma } from "./prisma"; import { Prisma } from "../../prisma/generated/client"; +const TELEGRAM_BOT_ID_FROM_TOKEN = (() => { + if (!process.env.TELEGRAM_BOT_TOKEN) return undefined; + const [candidate] = process.env.TELEGRAM_BOT_TOKEN.split(":", 1); + return candidate?.trim() || undefined; +})(); + export type TrafficAlertEvent = { level: "info" | "warning" | "error"; action: string; @@ -26,7 +31,95 @@ const TELEGRAM_CHAT_ID = ( process.env.VIBEWORKAIAGENTBOT_CHAT_ID || process.env.VIBEWORK_AI_BOT_CHAT_ID )?.trim(); +const TELEGRAM_FALLBACK_FROM_UPDATES = + process.env.TELEGRAM_FALLBACK_FROM_UPDATES?.trim().toLowerCase() === "true"; const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL?.trim(); +const TELEGRAM_NOTIFY_TIMEOUT_MS = Math.max( + 1, + Number.parseInt(process.env.TELEGRAM_NOTIFY_TIMEOUT_MS?.trim() || "3000", 10) || 3000 +); +const TELEGRAM_NOTIFY_MAX_ATTEMPTS = Math.max( + 1, + Number.parseInt(process.env.TELEGRAM_NOTIFY_MAX_ATTEMPTS?.trim() || "2", 10) || 2 +); +const TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS = Math.max( + 100, + Number.parseInt(process.env.TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS?.trim() || "400", 10) || 400 +); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let cachedTelegramFallbackChatId: string | null | undefined = undefined; + +function normalizeChatId(rawChatId: string | undefined) { + if (!rawChatId) return undefined; + const normalized = rawChatId.trim(); + if (!normalized) return undefined; + if (TELEGRAM_BOT_ID_FROM_TOKEN && normalized === TELEGRAM_BOT_ID_FROM_TOKEN) { + return undefined; + } + return normalized; +} + +async function resolveTelegramFallbackChatId(): Promise { + if (!TELEGRAM_BOT_TOKEN) { + return undefined; + } + + if (cachedTelegramFallbackChatId !== undefined) { + return cachedTelegramFallbackChatId || undefined; + } + + try { + const response = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=20`, { + method: "GET", + signal: AbortSignal.timeout(TELEGRAM_NOTIFY_TIMEOUT_MS), + }); + + if (!response.ok) { + return undefined; + } + + const data = (await response.json()) as { + result?: Array>; + }; + + const updates = Array.isArray(data.result) ? data.result : []; + for (let index = updates.length - 1; index >= 0; index -= 1) { + const update = updates[index]; + const chatCandidate = + (update?.message as { chat?: { id?: number | string } } | undefined)?.chat?.id ?? + (update?.edited_message as { chat?: { id?: number | string } } | undefined)?.chat?.id ?? + (update?.callback_query as { message?: { chat?: { id?: number | string } } } | undefined)?.message?.chat?.id ?? + (update?.channel_post as { chat?: { id?: number | string } } | undefined)?.chat?.id; + + const normalized = normalizeChatId(String(chatCandidate ?? "")); + if (normalized) { + cachedTelegramFallbackChatId = normalized; + console.log(`[Traffic alert] Resolved Telegram chat_id from updates: ${normalized}`); + return normalized; + } + } + + cachedTelegramFallbackChatId = null; + return undefined; + } catch (error) { + console.warn("[Traffic alert] resolveTelegramFallbackChatId failed", error); + cachedTelegramFallbackChatId = null; + return undefined; + } +} + +async function resolveTelegramChatId(): Promise { + const explicitChatId = normalizeChatId(TELEGRAM_CHAT_ID); + if (explicitChatId) return explicitChatId; + + if (!TELEGRAM_FALLBACK_FROM_UPDATES) { + return undefined; + } + + return resolveTelegramFallbackChatId(); +} function escapeMarkdown(value: unknown) { if (value === null || value === undefined) return ""; @@ -55,43 +148,34 @@ function buildTelegramMessage(event: TrafficAlertEvent) { async function sendViaHttps(url: string, body: Record) { return new Promise((resolve, reject) => { try { - const parsed = new URL(url); const payload = JSON.stringify(body); + let responseBody = ""; - const requestHandle = request( - { - method: "POST", - hostname: parsed.hostname, - path: `${parsed.pathname}${parsed.search}`, - port: 443, - protocol: "https:", - headers: { - "content-type": "application/json", - "content-length": Buffer.byteLength(payload), - }, + fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(payload)), }, - (response) => { - let responseBody = ""; - response.on("data", (chunk) => { - responseBody += chunk; - }); + body: payload, + signal: AbortSignal.timeout(TELEGRAM_NOTIFY_TIMEOUT_MS), + }) + .then(async (response) => { + responseBody = await response.text(); + if (response.status >= 200 && response.status < 300) { + resolve(); + return; + } - response.on("end", () => { - if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300) { - return resolve(); - } - - reject(new Error(`Telegram API ${response.statusCode}: ${responseBody.slice(0, 200)}`)); - }); - } - ); - - requestHandle.on("error", reject); - requestHandle.setTimeout(3000, () => { - requestHandle.destroy(new Error("Telegram request timeout")); - }); - requestHandle.write(payload); - requestHandle.end(); + reject(new Error(`Telegram API ${response.status}: ${responseBody.slice(0, 200)}`)); + }) + .catch((error) => { + if (error && error.name === "TimeoutError") { + reject(new Error(`Telegram API timeout ${TELEGRAM_NOTIFY_TIMEOUT_MS}ms`)); + return; + } + reject(error); + }); } catch (error) { reject(error); } @@ -153,6 +237,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise ...event, }; + const resolvedTelegramChatId = await resolveTelegramChatId(); + const notifyTargets = [ TRAFFIC_WEBHOOK_URL && { kind: "generic", @@ -179,7 +265,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise }), }, }, - TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID && { + TELEGRAM_BOT_TOKEN && + resolvedTelegramChatId && { kind: "telegram", url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, init: { @@ -187,11 +274,11 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise headers: { "content-type": "application/json", }, - body: JSON.stringify({ - chat_id: TELEGRAM_CHAT_ID, - text: buildTelegramMessage(event), - parse_mode: "MarkdownV2", - }), + body: JSON.stringify({ + chat_id: resolvedTelegramChatId, + text: buildTelegramMessage(event), + parse_mode: "MarkdownV2", + }), }, }, ].filter(Boolean) as Array<{ kind: string; url: string; init: RequestInit }>; @@ -205,7 +292,20 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise const payload = target.init.body ? JSON.parse(typeof target.init.body === "string" ? target.init.body : "{}") : {}; - await sendViaHttps(target.url, payload); + + let attempt = 0; + while (true) { + try { + await sendViaHttps(target.url, payload); + return; + } catch (error) { + attempt += 1; + if (attempt >= TELEGRAM_NOTIFY_MAX_ATTEMPTS) { + throw error; + } + await sleep(TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS * attempt); + } + } } else { const response = await fetch(target.url, { ...target.init, signal: AbortSignal.timeout(3000) }); if (!response.ok) {