fix(traffic): harden telegram target parsing and alert context
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
This commit is contained in:
@@ -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,..."
|
||||
|
||||
@@ -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, "&").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 (
|
||||
`<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>` +
|
||||
|
||||
Reference in New Issue
Block a user