fix: harden traffic telegram alert delivery and fallback chat id
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 6s

This commit is contained in:
OG T
2026-06-07 19:28:00 +08:00
parent d1765a78d9
commit 69cb463cbf

View File

@@ -1,7 +1,12 @@
import { request } from "node:https";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { Prisma } from "../../prisma/generated/client"; 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 = { export type TrafficAlertEvent = {
level: "info" | "warning" | "error"; level: "info" | "warning" | "error";
action: string; action: string;
@@ -26,7 +31,95 @@ const TELEGRAM_CHAT_ID = (
process.env.VIBEWORKAIAGENTBOT_CHAT_ID || process.env.VIBEWORKAIAGENTBOT_CHAT_ID ||
process.env.VIBEWORK_AI_BOT_CHAT_ID process.env.VIBEWORK_AI_BOT_CHAT_ID
)?.trim(); )?.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 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<string | undefined> {
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<Record<string, unknown>>;
};
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<string | undefined> {
const explicitChatId = normalizeChatId(TELEGRAM_CHAT_ID);
if (explicitChatId) return explicitChatId;
if (!TELEGRAM_FALLBACK_FROM_UPDATES) {
return undefined;
}
return resolveTelegramFallbackChatId();
}
function escapeMarkdown(value: unknown) { function escapeMarkdown(value: unknown) {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
@@ -55,43 +148,34 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
async function sendViaHttps(url: string, body: Record<string, unknown>) { async function sendViaHttps(url: string, body: Record<string, unknown>) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
try { try {
const parsed = new URL(url);
const payload = JSON.stringify(body); const payload = JSON.stringify(body);
let responseBody = "";
const requestHandle = request( fetch(url, {
{ method: "POST",
method: "POST", headers: {
hostname: parsed.hostname, "content-type": "application/json",
path: `${parsed.pathname}${parsed.search}`, "content-length": String(Buffer.byteLength(payload)),
port: 443,
protocol: "https:",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
},
}, },
(response) => { body: payload,
let responseBody = ""; signal: AbortSignal.timeout(TELEGRAM_NOTIFY_TIMEOUT_MS),
response.on("data", (chunk) => { })
responseBody += chunk; .then(async (response) => {
}); responseBody = await response.text();
if (response.status >= 200 && response.status < 300) {
resolve();
return;
}
response.on("end", () => { reject(new Error(`Telegram API ${response.status}: ${responseBody.slice(0, 200)}`));
if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300) { })
return resolve(); .catch((error) => {
} if (error && error.name === "TimeoutError") {
reject(new Error(`Telegram API timeout ${TELEGRAM_NOTIFY_TIMEOUT_MS}ms`));
reject(new Error(`Telegram API ${response.statusCode}: ${responseBody.slice(0, 200)}`)); return;
}); }
} reject(error);
); });
requestHandle.on("error", reject);
requestHandle.setTimeout(3000, () => {
requestHandle.destroy(new Error("Telegram request timeout"));
});
requestHandle.write(payload);
requestHandle.end();
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
@@ -153,6 +237,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
...event, ...event,
}; };
const resolvedTelegramChatId = await resolveTelegramChatId();
const notifyTargets = [ const notifyTargets = [
TRAFFIC_WEBHOOK_URL && { TRAFFIC_WEBHOOK_URL && {
kind: "generic", kind: "generic",
@@ -179,7 +265,8 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
}), }),
}, },
}, },
TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID && { TELEGRAM_BOT_TOKEN &&
resolvedTelegramChatId && {
kind: "telegram", kind: "telegram",
url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
init: { init: {
@@ -187,11 +274,11 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
chat_id: TELEGRAM_CHAT_ID, chat_id: resolvedTelegramChatId,
text: buildTelegramMessage(event), text: buildTelegramMessage(event),
parse_mode: "MarkdownV2", parse_mode: "MarkdownV2",
}), }),
}, },
}, },
].filter(Boolean) as Array<{ kind: string; url: string; init: RequestInit }>; ].filter(Boolean) as Array<{ kind: string; url: string; init: RequestInit }>;
@@ -205,7 +292,20 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
const payload = target.init.body const payload = target.init.body
? JSON.parse(typeof target.init.body === "string" ? 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 { } else {
const response = await fetch(target.url, { ...target.init, signal: AbortSignal.timeout(3000) }); const response = await fetch(target.url, { ...target.init, signal: AbortSignal.timeout(3000) });
if (!response.ok) { if (!response.ok) {