diff --git a/README.md b/README.md index 2669b56..4d2c08a 100644 --- a/README.md +++ b/README.md @@ -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_id;bot 本身不能作為訊息接收對象 +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" # optional:Scout 掃描參數 SCOUT_CRON_EXPRESSION="*/3 * * * *" SCOUT_TARGET_REPOS="open-webui/open-webui,microsoft/vscode,..." diff --git a/apps/web/src/lib/traffic-alert.ts b/apps/web/src/lib/traffic-alert.ts index f912f6e..c590c61 100644 --- a/apps/web/src/lib/traffic-alert.ts +++ b/apps/web/src/lib/traffic-alert.ts @@ -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(); 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 { +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): 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 { 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 { 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); + 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 { 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, "&").replace(//g, ">"); } +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 ( `VibeWork 流量告警` + @@ -140,8 +263,8 @@ function buildTelegramMessage(event: TrafficAlertEvent) { `\n- 行為: ${escapeHtml(event.action)}` + `\n- 通道: ${escapeHtml(event.surface)}` + `\n- Actor: ${escapeHtml(`${event.actorType}/${event.actorId}`)}` + - `\n- Source IP: ${escapeHtml(event.sourceIp || "n/a")}` + - `\n- User-Agent: ${escapeHtml(event.userAgent || "n/a")}` + + `\n- Source IP: ${escapeHtml(resolveDisplaySourceIp(event) || "n/a")}` + + `\n- User-Agent: ${escapeHtml(resolveDisplayUserAgent(event) || "n/a")}` + `\n- 回應: ${escapeHtml(typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a")}` + `\n- 任務: ${escapeHtml(event.taskId || "n/a")}` + `\n- request_id: ${escapeHtml(typeof event.metadata?.request_id === "string" ? event.metadata.request_id : "n/a")}` +