fix(traffic): harden telegram target parsing and alert context
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s

This commit is contained in:
OG T
2026-06-07 20:45:19 +08:00
parent 663ad4d3a2
commit 6a863d37d8
2 changed files with 148 additions and 19 deletions

View File

@@ -30,8 +30,14 @@ DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxx"
# Telegram 告警(可留空)
TELEGRAM_BOT_TOKEN="123456:abcdef"
TELEGRAM_CHAT_ID="-1001234567890"
# 可選:提供你想要推播的 Telegram 接收對象(會從 bot updates 反查 chat id
# TELEGRAM_CHAT_HANDLE="@your_handle"
# 注意:不能把 bot 的 @username 當 chat_idbot 本身不能作為訊息接收對象
TELEGRAM_CHAT_HANDLE="@your_telegram"
# 遇到明確 chat_id 時,若 TELEGRAM_CHAT_ID 像 bot id會自動忽略並回退
# optional只允許有 token 的 /api/traffic 查詢
TRAFFIC_MONITOR_TOKEN="your-monitor-token"
TELEGRAM_FALLBACK_FROM_UPDATES="true"
# optionalScout 掃描參數
SCOUT_CRON_EXPRESSION="*/3 * * * *"
SCOUT_TARGET_REPOS="open-webui/open-webui,microsoft/vscode,..."

View File

@@ -2,6 +2,7 @@ import { prisma } from "./prisma";
import { Prisma } from "../../prisma/generated/client";
import dns from "node:dns";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
const TELEGRAM_BOT_ID_FROM_TOKEN = (() => {
if (!process.env.TELEGRAM_BOT_TOKEN) return undefined;
@@ -33,6 +34,11 @@ const TELEGRAM_CHAT_ID = (
process.env.VIBEWORKAIAGENTBOT_CHAT_ID ||
process.env.VIBEWORK_AI_BOT_CHAT_ID
)?.trim();
const TELEGRAM_CHAT_HANDLE = (
process.env.TELEGRAM_CHAT_HANDLE ||
process.env.TELEGRAM_CHAT_USERNAME ||
process.env.TELEGRAM_CHAT_ALIAS
)?.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();
@@ -55,25 +61,101 @@ const TELEGRAM_IP_FAMILY = Number.parseInt(
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let cachedTelegramFallbackChatId: string | null | undefined = undefined;
const INVALID_CHAT_ID_MARKERS = new Set(["", "n/a", "na", "null", "undefined"]);
const cachedTelegramFallbackChatIdByTarget = new Map<string | undefined, string | null>();
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) {
const lowered = normalized.toLowerCase();
if (INVALID_CHAT_ID_MARKERS.has(lowered)) {
return undefined;
}
if (TELEGRAM_BOT_ID_FROM_TOKEN && normalized === TELEGRAM_BOT_ID_FROM_TOKEN) {
console.warn(
"[Traffic alert] TELEGRAM_CHAT_ID looks like bot id, skip as chat target to avoid sending to bot itself."
);
return undefined;
}
return normalized;
}
async function resolveTelegramFallbackChatId(): Promise<string | undefined> {
function normalizeTelegramHandle(rawHandle: string | undefined) {
if (!rawHandle) return undefined;
const normalized = rawHandle.trim();
if (!normalized) return undefined;
return normalized.startsWith("@") ? normalized.slice(1).toLowerCase() : normalized.toLowerCase();
}
type TelegramChatRecord = {
id: number | string;
username?: string;
title?: string;
};
function extractChatCandidates(update: Record<string, unknown>): TelegramChatRecord[] {
const candidates: TelegramChatRecord[] = [];
const messageContainer = (update.message as { chat?: TelegramChatRecord } | undefined)?.chat;
const editedMessageContainer = (update.edited_message as { chat?: TelegramChatRecord } | undefined)?.chat;
const callbackMessageContainer = (update.callback_query as { message?: { chat?: TelegramChatRecord } } | undefined)?.message
?.chat;
const channelPostContainer = (update.channel_post as { chat?: TelegramChatRecord } | undefined)?.chat;
const myChatMemberContainer = (update.my_chat_member as { chat?: TelegramChatRecord } | undefined)?.chat;
for (const container of [
messageContainer,
editedMessageContainer,
callbackMessageContainer,
channelPostContainer,
myChatMemberContainer,
]) {
if (!container || typeof container !== "object") continue;
const chat = container as TelegramChatRecord;
if (typeof chat.id === "number" || typeof chat.id === "string") {
candidates.push(chat);
}
}
return candidates;
}
function extractChatIdFromCandidates(
candidates: TelegramChatRecord[],
preferredHandle?: string
): string | undefined {
const handle = normalizeTelegramHandle(preferredHandle);
for (const chat of candidates) {
const chatId = normalizeChatId(String(chat.id));
if (!chatId) continue;
if (!handle) {
return chatId;
}
const username = normalizeTelegramHandle(chat.username);
const title = normalizeTelegramHandle(chat.title);
if (username === handle || title === handle) {
return chatId;
}
}
return undefined;
}
async function resolveTelegramFallbackChatId(
preferredHandle?: string
): Promise<string | undefined> {
if (!TELEGRAM_BOT_TOKEN) {
return undefined;
}
if (cachedTelegramFallbackChatId !== undefined) {
return cachedTelegramFallbackChatId || undefined;
const cacheKey = normalizeTelegramHandle(preferredHandle);
if (cachedTelegramFallbackChatIdByTarget.has(cacheKey)) {
const cached = cachedTelegramFallbackChatIdByTarget.get(cacheKey);
return cached || undefined;
}
try {
@@ -93,25 +175,30 @@ async function resolveTelegramFallbackChatId(): Promise<string | undefined> {
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;
if (!update || typeof update !== "object") continue;
const normalized = normalizeChatId(String(chatCandidate ?? ""));
if (normalized) {
cachedTelegramFallbackChatId = normalized;
console.log(`[Traffic alert] Resolved Telegram chat_id from updates: ${normalized}`);
return normalized;
const chatCandidates = extractChatCandidates(update as Record<string, unknown>);
const candidateId = extractChatIdFromCandidates(chatCandidates, cacheKey);
if (candidateId) {
cachedTelegramFallbackChatIdByTarget.set(cacheKey, candidateId);
const selector = cacheKey ? ` for ${cacheKey}` : "";
console.log(
`[Traffic alert] Resolved Telegram chat_id from updates${selector}: ${candidateId}`
);
return candidateId;
}
}
cachedTelegramFallbackChatId = null;
cachedTelegramFallbackChatIdByTarget.set(cacheKey, null);
if (cacheKey) {
console.warn(
`[Traffic alert] No Telegram chat match for handle=${cacheKey}; set a numeric TELEGRAM_CHAT_ID or message the bot first.`
);
}
return undefined;
} catch (error) {
console.warn("[Traffic alert] resolveTelegramFallbackChatId failed", error);
cachedTelegramFallbackChatId = null;
cachedTelegramFallbackChatIdByTarget.set(cacheKey, null);
return undefined;
}
}
@@ -120,6 +207,14 @@ async function resolveTelegramChatId(): Promise<string | undefined> {
const explicitChatId = normalizeChatId(TELEGRAM_CHAT_ID);
if (explicitChatId) return explicitChatId;
const configuredHandle = normalizeTelegramHandle(TELEGRAM_CHAT_HANDLE);
if (configuredHandle) {
const resolvedChatId = await resolveTelegramFallbackChatId(configuredHandle);
if (resolvedChatId) {
return resolvedChatId;
}
}
if (!TELEGRAM_FALLBACK_FROM_UPDATES) {
return undefined;
}
@@ -132,6 +227,34 @@ function escapeHtml(value: unknown) {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function extractIpFromActor(actorType: TrafficAlertEvent["actorType"], actorId: string) {
if (actorType !== "USER") {
return undefined;
}
const marker = actorId.lastIndexOf(":");
if (marker < 0) return undefined;
const maybeIp = actorId.slice(marker + 1).trim();
if (isIP(maybeIp) || maybeIp.includes(".") || maybeIp.includes(":")) {
return maybeIp;
}
return undefined;
}
function resolveDisplaySourceIp(event: TrafficAlertEvent) {
const metadataIp = typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined;
return event.sourceIp || metadataIp || extractIpFromActor(event.actorType, event.actorId);
}
function resolveDisplayUserAgent(event: TrafficAlertEvent) {
return (
event.userAgent ||
(typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined)
);
}
function buildTelegramMessage(event: TrafficAlertEvent) {
return (
`<b>VibeWork 流量告警</b>` +
@@ -140,8 +263,8 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
`\n- 行為: <code>${escapeHtml(event.action)}</code>` +
`\n- 通道: <code>${escapeHtml(event.surface)}</code>` +
`\n- Actor: <code>${escapeHtml(`${event.actorType}/${event.actorId}`)}</code>` +
`\n- Source IP: <code>${escapeHtml(event.sourceIp || "n/a")}</code>` +
`\n- User-Agent: <code>${escapeHtml(event.userAgent || "n/a")}</code>` +
`\n- Source IP: <code>${escapeHtml(resolveDisplaySourceIp(event) || "n/a")}</code>` +
`\n- User-Agent: <code>${escapeHtml(resolveDisplayUserAgent(event) || "n/a")}</code>` +
`\n- 回應: <code>${escapeHtml(typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a")}</code>` +
`\n- 任務: <code>${escapeHtml(event.taskId || "n/a")}</code>` +
`\n- request_id: <code>${escapeHtml(typeof event.metadata?.request_id === "string" ? event.metadata.request_id : "n/a")}</code>` +