From c5fbb3b3c99e6d42df271edfe2ec959b58b13bf8 Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:17:31 +0800 Subject: [PATCH 1/5] feat: add fbi plugin - cross-group message tracking --- fbi/fbi.ts | 470 +++++++++++++++++++++++++++++++++++++++++++++++++++ plugins.json | 4 + 2 files changed, 474 insertions(+) create mode 100644 fbi/fbi.ts diff --git a/fbi/fbi.ts b/fbi/fbi.ts new file mode 100644 index 0000000..210a503 --- /dev/null +++ b/fbi/fbi.ts @@ -0,0 +1,470 @@ +import { Plugin } from "@utils/pluginBase"; +import { getGlobalClient } from "@utils/globalClient"; +import { getPrefixes } from "@utils/pluginManager"; +import { safeGetReplyMessage } from "@utils/safeGetMessages"; +import { createDirectoryInAssets } from "@utils/pathHelpers"; +import { Api } from "teleproto"; +import { JSONFilePreset } from "lowdb/node"; +import * as path from "path"; + +const PREFIX = getPrefixes()[0]; +const HELP = `🕵️ FBI 跨群组追踪 + +• ${PREFIX}fbi cs [目标] — 搜索目标最新消息 +• ${PREFIX}fbi sv [目标] — 蹲守目标下一条消息 +• ${PREFIX}fbi ds [目标] — 分析目标最活跃群组 +• ${PREFIX}fbi ssv — 终止所有蹲守 +• ${PREFIX}fbi cache — 查看/管理消息缓存 +• ${PREFIX}fbi help — 本帮助 + +目标可为 @用户名、用户ID,或回复消息自动取被回复者。`; + +const CACHE_MSG_LIMIT = 200; // max messages stored per group +const CACHE_LIMIT_DEF = 300; // default max groups to cache +const CACHE_LIMIT_MIN = 10; +const CACHE_LIMIT_MAX = 1000; + +const htmlEsc = (s: string) => + s.replace(/[&<>"']/g, (m) => { + if (m === "&") return "&"; + if (m === "<") return "<"; + if (m === ">") return ">"; + if (m === '"') return """; + return "'"; + }); + +const peelChatId = (id: any) => String(typeof id === "bigint" ? id.toString() : id).replace(/^-100/, ""); + +function msgPreview(msg: Api.Message): string { + let t = msg.text || ""; + if (!t && msg.media) t = "[媒体消息]"; + return t.length > 50 ? t.slice(0, 50) + "…" : t || "[空消息]"; +} + +/** random delay between min and max ms */ +function rndDelay(min = 500, max = 2000): Promise { + const ms = Math.floor(Math.random() * (max - min + 1)) + min; + return new Promise((r) => setTimeout(r, ms)); +} + +interface CachedChat { + username?: string; + title?: string; + msgs: Api.Message[]; +} + +interface SvEntry { + targetId: string; + targetName: string; + triggerPeer: string; + triggerMsgId: number; +} + +interface FbiDB { + surveillance: Record; + cacheLimit: number; +} + +const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF }; + +class FbiPlugin extends Plugin { + description = `FBI 跨群组追踪\n\n${HELP}`; + + cmdHandlers = { fbi: this.onCmd.bind(this) }; + listenMessageHandler = this.onMsg.bind(this); + + private db: any; + private sv = new Map(); + private chatCache = new Map(); + private cacheReady = false; + + constructor() { + super(); + this.initDB().catch((e) => console.error("[fbi] initDB error", e)); + } + + /* ====== bootstrap ====== */ + + private async initDB() { + const p = path.join(createDirectoryInAssets("fbi"), "db.json"); + this.db = await JSONFilePreset(p, DB_DEF); + // ensure cacheLimit has a default + if (typeof this.db.data.cacheLimit !== "number") { + this.db.data.cacheLimit = CACHE_LIMIT_DEF; + await this.db.write(); + } + for (const [k, v] of Object.entries(this.db.data.surveillance)) this.sv.set(k, v); + // cache populated live via onMsg — no startup scan needed + this.cacheReady = true; + } + + private async persistDb() { + this.db.data.surveillance = Object.fromEntries(this.sv); + await this.db.write(); + } + + /** scan public groups one-by-one with random delay, fill chatCache */ + private async buildCache() { + const cl = await getGlobalClient(); + if (!cl) return; + + const limit = this.db.data.cacheLimit; + const dialogs = ((await cl.getDialogs({})) as any[]) + .filter((d: any) => d.isGroup || d.isChat) + .slice(0, limit); + + let count = 0; + for (const d of dialogs) { + const msgs: Api.Message[] = []; + for await (const m of cl.iterMessages(d.id, { limit: CACHE_MSG_LIMIT })) { + msgs.push(m); + } + // get entity for username/title + let username: string | undefined; + let title: string | undefined; + try { + const entity = await cl.getEntity(d.id); + username = entity.username; + title = entity.title; + } catch { /* best-effort */ } + this.chatCache.set(String(d.id), { username, title, msgs }); + count++; + await rndDelay(); + } + + this.cacheReady = true; + console.log(`[fbi] cache ready: ${count} groups (limit ${limit})`); + } + + /* ====== router ====== */ + + private async onCmd(msg: Api.Message) { + const parts = msg.text?.trim().split(/\s+/) || []; + const sub = parts[1]?.toLowerCase(); + const args = parts.slice(2); + + try { + switch (sub) { + case "cs": return this.doCs(msg, args); + case "sv": return this.doSv(msg, args); + case "ds": return this.doDs(msg, args); + case "ssv": return this.doSsv(msg); + case "cache": return this.doCache(msg, args); + default: return msg.edit({ text: HELP, parseMode: "html" }); + } + } catch (e: any) { + await msg.edit({ text: `❌ ${htmlEsc(e.message || "未知错误")}`, parseMode: "html" }); + } + } + + /* ====== target resolver ====== */ + + private async resolveTarget(msg: Api.Message, args: string[]): Promise<{ id: string; name: string } | null> { + const cl = await getGlobalClient(); + if (!cl) return null; + let targetId: string | undefined; + let name = ""; + + if (args.length) { + const raw = args[0]; + try { + const e = await cl.getEntity(raw); + targetId = String(e.id); + name = e.username ? `@${e.username}` : e.firstName || e.title || targetId; + } catch { + name = raw; + targetId = raw.replace(/^@/, ""); + } + } else if (msg.isReply) { + const r = await safeGetReplyMessage(msg); + if (r?.senderId) { + targetId = String(r.senderId); + try { + const u = await cl.getEntity(r.senderId as any); + name = u.username ? `@${u.username}` : u.firstName || targetId; + } catch { + name = targetId; + } + } + } + return targetId ? { id: targetId, name } : null; + } + + /* ====== link builder ====== */ + + private async msgLink(cl: any, msg: Api.Message): Promise { + const chat = await cl.getEntity(msg.peerId); + const text = htmlEsc(msgPreview(msg)); + const mid = msg.id; + if (chat.username) return `${text}`; + return `${text}`; + } + + /* ====== cs (zero-request, reads cache) ====== */ + + private async doCs(msg: Api.Message, args: string[]) { + if (!this.cacheReady) { + await msg.edit({ text: "⏳ 缓存正在初始化,请稍后再试。", parseMode: "html" }); + return; + } + const target = await this.resolveTarget(msg, args); + if (!target) { + await msg.edit({ text: "❌ 无法识别目标,请回复消息或提供用户名/ID", parseMode: "html" }); + return; + } + + await msg.edit({ text: `🔍 正在搜索嫌疑人 ${target.name} 的蛛丝马迹...`, parseMode: "html" }); + + let found: { msg: Api.Message; peer: string } | null = null; + + for (const [peer, chat] of this.chatCache) { + for (const m of chat.msgs) { + if (String(m.senderId) === target.id) { + if (!found || m.date > found.msg.date) found = { msg: m, peer }; + break; // newest msg in this group, move on + } + } + } + + await msg.delete().catch(() => {}); + + if (!found) { + await this.sendReply(msg, `🤦‍♀ 暂时没发现 ${target.name} 有作案嫌疑。`); + return; + } + + const cl = await getGlobalClient(); + if (!cl) return; + // debug: log what we matched + console.log(`[fbi:cs] target.id=${target.id} target.name=${target.name}`); + console.log(`[fbi:cs] found msg: chat=${found.peer} mid=${found.msg.id} senderId=${String(found.msg.senderId)} date=${found.msg.date} text="${found.msg.text?.slice(0,60)}"`); + const link = await this.msgLink(cl, found.msg); + const userTag = `${htmlEsc(target.name)}`; + await this.sendReply(msg, `👀 发现嫌疑人 ${userTag} 的作案现场:\n\n${link}\n\n要想人不知除非己莫为。`); + } + + /* ====== sv ====== */ + + private async doSv(msg: Api.Message, args: string[]) { + const cl = await getGlobalClient(); + if (!cl) return; + const target = await this.resolveTarget(msg, args); + if (!target) { + await msg.edit({ text: "❌ 无法识别目标", parseMode: "html" }); + return; + } + + await msg.edit({ text: `👁️ 正在对嫌疑人 ${target.name} 进行蹲守...`, parseMode: "html" }); + + this.sv.set(target.id, { + targetId: target.id, + targetName: target.name, + triggerPeer: String(msg.chatId), + triggerMsgId: msg.id, + }); + await this.persistDb(); + } + + /* ====== listen — update cache + check surveillance ====== */ + + private async onMsg(msg: Api.Message) { + // 1) update cache — only public groups, auto-vivify on first sighting + if (msg.chatId) { + const peer = String(msg.chatId); + let chat = this.chatCache.get(peer); + if (!chat) { + // first sighting → check if public, cache only public groups + const cl = await getGlobalClient(); + if (cl) { + try { + const entity = await cl.getEntity(msg.chatId); + if (entity.username && (entity.className === 'Channel' || entity.className === 'Chat')) { + chat = { username: entity.username, title: entity.title, msgs: [] }; + this.chatCache.set(peer, chat); + } + } catch { /* getEntity failed → skip */ } + } + } + if (chat) { + chat.msgs.unshift(msg); + if (chat.msgs.length > CACHE_MSG_LIMIT) chat.msgs.length = CACHE_MSG_LIMIT; + } + } + + // 2) sv surveillance + if (this.sv.size === 0) return; + const sid = msg.senderId ? String(msg.senderId) : ""; + if (!sid || !this.sv.has(sid)) return; + const entry = this.sv.get(sid)!; + + // prevent self-trigger + if (String(msg.chatId) === entry.triggerPeer && msg.id === entry.triggerMsgId) return; + + this.sv.delete(sid); + this.persistDb().catch(() => {}); + + const cl = await getGlobalClient(); + if (!cl) return; + + const link = await this.msgLink(cl, msg); + const userTag = `${htmlEsc(entry.targetName)}`; + const result = `🚨 发现嫌疑人 ${userTag} 最新动向\n\n${link}\n\n天网恢恢疏而不漏。`; + + // ponytail: client handles channel/normal, one call suffices + try { await cl.deleteMessages(entry.triggerPeer, [entry.triggerMsgId], { revoke: false }); } catch {} + + await cl.sendMessage(entry.triggerPeer, { message: result, parseMode: "html", linkPreview: false }); + await cl.sendMessage("me", { message: result, parseMode: "html", linkPreview: false }); + } + + /* ====== ds (zero-request, reads cache) ====== */ + + private async doDs(msg: Api.Message, args: string[]) { + if (!this.cacheReady) { + await msg.edit({ text: "⏳ 缓存正在初始化,请稍后再试。", parseMode: "html" }); + return; + } + const target = await this.resolveTarget(msg, args); + if (!target) { + await msg.edit({ text: "❌ 无法识别目标", parseMode: "html" }); + return; + } + + await msg.edit({ text: `🧭 正在摸排嫌疑人 ${target.name} 的窝点...`, parseMode: "html" }); + + let bestPeer: string | null = null; + let bestCount = 0; + let bestMsg: Api.Message | null = null; + + for (const [peer, chat] of this.chatCache) { + let cnt = 0; + let latest: Api.Message | null = null; + for (const m of chat.msgs) { + if (String(m.senderId) === target.id) { + cnt++; + if (!latest) latest = m; // first hit = newest (msgs newest-first) + } + } + if (cnt > bestCount) { + bestCount = cnt; + bestPeer = peer; + bestMsg = latest; + } + } + + await msg.delete().catch(() => {}); + + if (!bestPeer || bestCount === 0) { + await this.sendReply(msg, `🤦‍♀ 摸排结果不尽人意,嫌疑人 ${target.name} 藏的很深。`); + return; + } + + // debug: log ds match + if (bestMsg) { + console.log(`[fbi:ds] target.id=${target.id} target.name=${target.name}`); + console.log(`[fbi:ds] best group=${bestPeer} count=${bestCount} mid=${bestMsg.id} senderId=${String(bestMsg.senderId)} date=${bestMsg.date} text="${bestMsg.text?.slice(0,60)}"`); + } + // ponytail: link text = group name, href points to target's latest msg in that group + let link = `https://t.me/c/${peelChatId(bestPeer)}`; + if (bestMsg) { + const chat = this.chatCache.get(bestPeer)!; + const mid = bestMsg.id; + const href = chat.username ? `https://t.me/${chat.username}/${mid}` : `https://t.me/c/${peelChatId(bestPeer)}/${mid}`; + link = `${htmlEsc(chat.title || chat.username || bestPeer)}`; + } + const userTag = `${htmlEsc(target.name)}`; + await this.sendReply(msg, `🏚 发现嫌疑人 ${userTag} 的窝点:\n\n${link}\n\n跑得了和尚跑不了庙。`); + } + + /* ====== ssv ====== */ + + private async doSsv(msg: Api.Message) { + if (this.sv.size === 0) { + await msg.edit({ text: "❌ 当前没有活跃的蹲守任务。", parseMode: "html" }); + return; + } + + const cl = await getGlobalClient(); + + for (const entry of this.sv.values()) { + if (cl) { + try { + await cl.invoke( + new Api.messages.EditMessage({ + peer: entry.triggerPeer, + id: entry.triggerMsgId, + message: `🤦‍♀ 蹲守过程中没有发现嫌疑人 ${entry.targetName} 的行踪。`, + parseMode: "html" as any, + }), + ); + } catch { /* trigger may be deleted — fine */ } + } + } + + this.sv.clear(); + await this.persistDb(); + await msg.edit({ text: "✅ 已终止所有蹲守任务。", parseMode: "html" }); + } + + /* ====== cache management ====== */ + + private async doCache(msg: Api.Message, args: string[]) { + const sub = args[0]?.toLowerCase(); + + if (sub === "limit") { + const n = parseInt(args[1], 10); + if (isNaN(n) || n < CACHE_LIMIT_MIN || n > CACHE_LIMIT_MAX) { + await msg.edit({ + text: `❌ 缓存上限必须为 ${CACHE_LIMIT_MIN}~${CACHE_LIMIT_MAX} 之间的整数。当前:${this.db.data.cacheLimit}`, + parseMode: "html", + }); + return; + } + this.db.data.cacheLimit = n; + await this.db.write(); + await msg.edit({ + text: `✅ 缓存上限已设为 ${n} 个群组。使用 ${PREFIX}fbi cache rebuild 重新构建。`, + parseMode: "html", + }); + return; + } + + if (sub === "rebuild") { + await msg.edit({ text: "🔄 正在重建缓存,可能需要几分钟...", parseMode: "html" }); + this.cacheReady = false; + this.chatCache.clear(); + await this.buildCache(); + await msg.edit({ + text: `✅ 缓存重建完成,共缓存 ${this.chatCache.size} 个群组。`, + parseMode: "html", + }); + return; + } + + // status + const limit = this.db.data.cacheLimit; + await msg.edit({ + text: `📦 FBI 缓存状态 + +• 已缓存群组:${this.chatCache.size} +• 缓存上限:${limit} +• 每群最大消息:${CACHE_MSG_LIMIT} +• 状态:${this.cacheReady ? "✅ 就绪" : "⏳ 初始化中"} + +子命令 +• ${PREFIX}fbi cache limit [数量] — 设置缓存上限(${CACHE_LIMIT_MIN}~${CACHE_LIMIT_MAX}) +• ${PREFIX}fbi cache rebuild — 重建缓存`, + parseMode: "html", + }); + } + + /* ====== helper ====== */ + + private async sendReply(original: Api.Message, text: string) { + const cl = await getGlobalClient(); + if (!cl) return; + await cl.sendMessage(original.peerId, { message: text, parseMode: "html", linkPreview: false }); + } +} + +export default new FbiPlugin(); diff --git a/plugins.json b/plugins.json index ee297fa..8de9176 100644 --- a/plugins.json +++ b/plugins.json @@ -175,6 +175,10 @@ "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/epic/epic.ts?raw=true", "desc": "检查Epic Games喜加一优惠" }, + "fbi": { + "url": "https://github.com/xiaochengshiguduo/TeleBox_Plugins/blob/main/fbi/fbi.ts?raw=true", + "desc": "FBI - 跨群组消息追踪 / 蹲守 / 活跃群组分析" + }, "fadian": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/fadian/fadian.ts?raw=true", "desc": "fadian语录" From b335ff32384ff710b84f4bf99b46346bea6046a2 Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:20:00 +0800 Subject: [PATCH 2/5] fix: use upstream URL in plugins.json --- plugins.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins.json b/plugins.json index 8de9176..fd93055 100644 --- a/plugins.json +++ b/plugins.json @@ -176,7 +176,7 @@ "desc": "检查Epic Games喜加一优惠" }, "fbi": { - "url": "https://github.com/xiaochengshiguduo/TeleBox_Plugins/blob/main/fbi/fbi.ts?raw=true", + "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/fbi/fbi.ts?raw=true", "desc": "FBI - 跨群组消息追踪 / 蹲守 / 活跃群组分析" }, "fadian": { From 2276ca05bc4a3474c0671a315de2534694f666df Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:30:07 +0800 Subject: [PATCH 3/5] feat: persist cache to lowdb, strip messages --- fbi/fbi.ts | 78 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/fbi/fbi.ts b/fbi/fbi.ts index 210a503..ca9305e 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -35,10 +35,8 @@ const htmlEsc = (s: string) => const peelChatId = (id: any) => String(typeof id === "bigint" ? id.toString() : id).replace(/^-100/, ""); -function msgPreview(msg: Api.Message): string { - let t = msg.text || ""; - if (!t && msg.media) t = "[媒体消息]"; - return t.length > 50 ? t.slice(0, 50) + "…" : t || "[空消息]"; +function stripMsg(m: Api.Message): CachedMsg { + return { id: m.id, senderId: Number(m.senderId), date: m.date, text: m.text || "" }; } /** random delay between min and max ms */ @@ -47,10 +45,18 @@ function rndDelay(min = 500, max = 2000): Promise { return new Promise((r) => setTimeout(r, ms)); } +/** Minimal serializable representation of a chat message */ +interface CachedMsg { + id: number; + senderId: number; + date: number; + text: string; +} + interface CachedChat { username?: string; title?: string; - msgs: Api.Message[]; + msgs: CachedMsg[]; } interface SvEntry { @@ -63,9 +69,10 @@ interface SvEntry { interface FbiDB { surveillance: Record; cacheLimit: number; + cache: Record; } -const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF }; +const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF, cache: {} }; class FbiPlugin extends Plugin { description = `FBI 跨群组追踪\n\n${HELP}`; @@ -94,7 +101,12 @@ class FbiPlugin extends Plugin { await this.db.write(); } for (const [k, v] of Object.entries(this.db.data.surveillance)) this.sv.set(k, v); - // cache populated live via onMsg — no startup scan needed + // load persisted cache into memory + if (this.db.data.cache) { + for (const [k, v] of Object.entries(this.db.data.cache)) + this.chatCache.set(k, v as CachedChat); + console.log(`[fbi] cache loaded: ${this.chatCache.size} groups`); + } this.cacheReady = true; } @@ -115,9 +127,9 @@ class FbiPlugin extends Plugin { let count = 0; for (const d of dialogs) { - const msgs: Api.Message[] = []; + const msgs: CachedMsg[] = []; for await (const m of cl.iterMessages(d.id, { limit: CACHE_MSG_LIMIT })) { - msgs.push(m); + msgs.push(stripMsg(m)); } // get entity for username/title let username: string | undefined; @@ -127,11 +139,17 @@ class FbiPlugin extends Plugin { username = entity.username; title = entity.title; } catch { /* best-effort */ } - this.chatCache.set(String(d.id), { username, title, msgs }); - count++; + // ponytail: skip private groups (no username) + if (username) { + this.chatCache.set(String(d.id), { username, title, msgs }); + count++; + } await rndDelay(); } + // persist to disk + this.db.data.cache = Object.fromEntries(this.chatCache); + await this.db.write(); this.cacheReady = true; console.log(`[fbi] cache ready: ${count} groups (limit ${limit})`); } @@ -190,16 +208,6 @@ class FbiPlugin extends Plugin { return targetId ? { id: targetId, name } : null; } - /* ====== link builder ====== */ - - private async msgLink(cl: any, msg: Api.Message): Promise { - const chat = await cl.getEntity(msg.peerId); - const text = htmlEsc(msgPreview(msg)); - const mid = msg.id; - if (chat.username) return `${text}`; - return `${text}`; - } - /* ====== cs (zero-request, reads cache) ====== */ private async doCs(msg: Api.Message, args: string[]) { @@ -215,7 +223,7 @@ class FbiPlugin extends Plugin { await msg.edit({ text: `🔍 正在搜索嫌疑人 ${target.name} 的蛛丝马迹...`, parseMode: "html" }); - let found: { msg: Api.Message; peer: string } | null = null; + let found: { msg: CachedMsg; peer: string } | null = null; for (const [peer, chat] of this.chatCache) { for (const m of chat.msgs) { @@ -233,12 +241,15 @@ class FbiPlugin extends Plugin { return; } - const cl = await getGlobalClient(); - if (!cl) return; // debug: log what we matched console.log(`[fbi:cs] target.id=${target.id} target.name=${target.name}`); - console.log(`[fbi:cs] found msg: chat=${found.peer} mid=${found.msg.id} senderId=${String(found.msg.senderId)} date=${found.msg.date} text="${found.msg.text?.slice(0,60)}"`); - const link = await this.msgLink(cl, found.msg); + console.log(`[fbi:cs] found msg: chat=${found.peer} mid=${found.msg.id} senderId=${found.msg.senderId} date=${found.msg.date} text="${found.msg.text.slice(0,60)}"`); + // ponytail: build link from cache, no API call needed + const chat = this.chatCache.get(found.peer)!; + const text = htmlEsc(found.msg.text.slice(0, 50)); + const link = chat.username + ? `${text}` + : `${text}`; const userTag = `${htmlEsc(target.name)}`; await this.sendReply(msg, `👀 发现嫌疑人 ${userTag} 的作案现场:\n\n${link}\n\n要想人不知除非己莫为。`); } @@ -286,7 +297,7 @@ class FbiPlugin extends Plugin { } } if (chat) { - chat.msgs.unshift(msg); + chat.msgs.unshift(stripMsg(msg)); if (chat.msgs.length > CACHE_MSG_LIMIT) chat.msgs.length = CACHE_MSG_LIMIT; } } @@ -306,7 +317,12 @@ class FbiPlugin extends Plugin { const cl = await getGlobalClient(); if (!cl) return; - const link = await this.msgLink(cl, msg); + // ponytail: link from live msg entity (need peerId for unknown groups) + const chatEntity = await cl.getEntity(msg.peerId); + const preview = htmlEsc((msg.text || "").slice(0, 50) || "[空消息]"); + const link = chatEntity.username + ? `${preview}` + : `${preview}`; const userTag = `${htmlEsc(entry.targetName)}`; const result = `🚨 发现嫌疑人 ${userTag} 最新动向\n\n${link}\n\n天网恢恢疏而不漏。`; @@ -334,11 +350,11 @@ class FbiPlugin extends Plugin { let bestPeer: string | null = null; let bestCount = 0; - let bestMsg: Api.Message | null = null; + let bestMsg: CachedMsg | null = null; for (const [peer, chat] of this.chatCache) { let cnt = 0; - let latest: Api.Message | null = null; + let latest: CachedMsg | null = null; for (const m of chat.msgs) { if (String(m.senderId) === target.id) { cnt++; @@ -362,7 +378,7 @@ class FbiPlugin extends Plugin { // debug: log ds match if (bestMsg) { console.log(`[fbi:ds] target.id=${target.id} target.name=${target.name}`); - console.log(`[fbi:ds] best group=${bestPeer} count=${bestCount} mid=${bestMsg.id} senderId=${String(bestMsg.senderId)} date=${bestMsg.date} text="${bestMsg.text?.slice(0,60)}"`); + console.log(`[fbi:ds] best group=${bestPeer} count=${bestCount} mid=${bestMsg.id} senderId=${bestMsg.senderId} date=${bestMsg.date} text="${bestMsg.text.slice(0,60)}"`); } // ponytail: link text = group name, href points to target's latest msg in that group let link = `https://t.me/c/${peelChatId(bestPeer)}`; From 05d56a2cf971039735d6271227f7fb10bb67676f Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:49:45 +0800 Subject: [PATCH 4/5] fix: auto-persist cache on onMsg via debounced writes --- fbi/fbi.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/fbi/fbi.ts b/fbi/fbi.ts index ca9305e..1f80b57 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -84,6 +84,8 @@ class FbiPlugin extends Plugin { private sv = new Map(); private chatCache = new Map(); private cacheReady = false; + private cacheDirty = false; + private cachePersistTimer: ReturnType | null = null; constructor() { super(); @@ -115,6 +117,19 @@ class FbiPlugin extends Plugin { await this.db.write(); } + /** debounced cache persist — at most once every 10s */ + private schedulePersistCache() { + this.cacheDirty = true; + if (this.cachePersistTimer) return; // already queued + this.cachePersistTimer = setTimeout(async () => { + this.cachePersistTimer = null; + if (!this.cacheDirty) return; + this.cacheDirty = false; + this.db.data.cache = Object.fromEntries(this.chatCache); + await this.db.write(); + }, 10_000); + } + /** scan public groups one-by-one with random delay, fill chatCache */ private async buildCache() { const cl = await getGlobalClient(); @@ -299,6 +314,7 @@ class FbiPlugin extends Plugin { if (chat) { chat.msgs.unshift(stripMsg(msg)); if (chat.msgs.length > CACHE_MSG_LIMIT) chat.msgs.length = CACHE_MSG_LIMIT; + this.schedulePersistCache(); } } From a22fa72bcee677f65466c11336337294a6a2cdb8 Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:55:32 +0800 Subject: [PATCH 5/5] chore: remove debug console.log from cs/ds --- fbi/fbi.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/fbi/fbi.ts b/fbi/fbi.ts index 1f80b57..59a13b8 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -256,10 +256,7 @@ class FbiPlugin extends Plugin { return; } - // debug: log what we matched - console.log(`[fbi:cs] target.id=${target.id} target.name=${target.name}`); - console.log(`[fbi:cs] found msg: chat=${found.peer} mid=${found.msg.id} senderId=${found.msg.senderId} date=${found.msg.date} text="${found.msg.text.slice(0,60)}"`); - // ponytail: build link from cache, no API call needed + // build link from cache, no API call needed const chat = this.chatCache.get(found.peer)!; const text = htmlEsc(found.msg.text.slice(0, 50)); const link = chat.username @@ -391,12 +388,7 @@ class FbiPlugin extends Plugin { return; } - // debug: log ds match - if (bestMsg) { - console.log(`[fbi:ds] target.id=${target.id} target.name=${target.name}`); - console.log(`[fbi:ds] best group=${bestPeer} count=${bestCount} mid=${bestMsg.id} senderId=${bestMsg.senderId} date=${bestMsg.date} text="${bestMsg.text.slice(0,60)}"`); - } - // ponytail: link text = group name, href points to target's latest msg in that group + // link text = group name, href points to target's latest msg in that group let link = `https://t.me/c/${peelChatId(bestPeer)}`; if (bestMsg) { const chat = this.chatCache.get(bestPeer)!;