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/8] 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/8] 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/8] 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/8] 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/8] 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)!; From 1549dafbacfe9ba73cdb3772e2abaefe8f471a2e Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:38:08 +0800 Subject: [PATCH 6/8] fix: paginate getDialogs, move stripMsg after interfaces, sync docs --- README.md | 227 +++++++++++++++++------------------------------------ fbi/fbi.ts | 30 +++++-- 2 files changed, 97 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 8378d31..df25522 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,79 @@ -# TeleBox_Plugins +# TeleBox FBI 🕵️ -## 简介 -TeleBox_Plugins 是 [TeleBox](https://github.com/TeleBoxOrg/TeleBox) 项目的官方插件仓库,提供丰富的功能扩展。 +跨群组消息追踪插件 — 单文件,零新增依赖。 + +## 命令 + +| 命令 | 功能 | +|---|---| +| `.fbi cs [目标]` | 🔍 搜索目标在**公开群**的最新消息(内存缓存,零 API) | +| `.fbi sv [目标]` | 👁️ 蹲守目标下一条消息(实时监听) | +| `.fbi ds [目标]` | 🧭 分析目标最活跃的**公开群**(内存缓存,零 API) | +| `.fbi ssv` | 🛑 终止所有蹲守 | +| `.fbi cache` | 📦 查看缓存状态 | +| `.fbi cache rebuild` | 🔄 手动重建缓存(拉取全部公开群) | +| `.fbi cache limit [N]` | 设置拉取上限(10~1000,默认 300) | +| `.fbi help` | 显示帮助 | + +### 目标格式 + +- `@username` +- 用户 ID(纯数字) +- 回复一条消息(自动取被回复者) + +## 快速开始 -## 安装方式 ```bash -tpm i <插件名> +# 复制到 TeleBox 插件目录 +scp fbi.ts user@host:/path/to/TeleBox/plugins/ + +# 重启 TeleBox +pm2 restart telebox +``` + +## 架构 + +### 缓存 + +cs 和 ds 不调任何 Telegram API。数据来源是实时的内存 + 磁盘双写缓存: + +``` +启动 → 从 db.json 加载缓存到内存 (chatCache) + → listenMessageHandler 每收到消息更新内存 + → debounced 10s 自动写盘 (schedulePersistCache) + → 重启从文件恢复,无需 rebuild ``` -## 可用插件列表 -- `aban` - 高级封禁管理 -- `acron` - 定时发送/转发/复制/置顶/取消置顶/删除消息/执行命令 -- `admin_board` - 管理员排行榜 末位淘汰 -- `aff` - 机场Aff信息管理 -- `ai` - ai聚合 -- `aitc` - AI Prompt 转写 -- `annualreport` - 年度报告 -- `atadmins` - 一键艾特全部管理员 -- `atall` - 一键艾特全部成员 -- `audio_to_voice` - 音乐转音频 -- `autochangename` - 自动定时修改用户名插 -- `autodel` - 定时删除消息 -- `autodelcmd` - 自动删除命令消息 -- `autorepeat` - 智能自动复读机 -- `banana` - Nano-Banana 图像编辑 -- `bgp` - BGP路由图查询工具 -- `biko` - 批量获取整理发送指定对话中指定用户的消息 -- `bin` - 卡头检测 -- `bizhi` - 发送一张壁纸 -- `botmzt` - 随机获取写真图片 -- `bs` - 保送 -- `bulk_delete` - 批量删除消息 -- `calc` - 计算器 -- `checkin` - 自动签到插件 -- `clean` - 账号清理工具 Pro -- `clean_member` - 群组成员清理 -- `clear_sticker` - 批量删除群组内贴纸 -- `codex_image` - 通过codex调用gpt-image-2 -- `convert` - 视频转音频 -- `copy_sticker_set` - 复制贴纸包 -- `cosplay` - 获取随机cos写真 -- `crazy4` - 疯狂星期四文案 -- `cy` - 词云 -- `da` - 删除群内所有消息 -- `dbdj` - 点兵点将 - 从最近的消息中随机抽取指定人数的用户 -- `dc` - 获取实体DC -- `deepwiki` - DeepWiki多项目聚合 -- `dig` - DNS 查询 -- `diss` - 儒雅随和版祖安语录 -- `dme` - 删除指定数量的自己发送的消息 -- `eat` - 生成带头像表情包 -- `eatgif` - 生成"吃掉"动图表情包 -- `encode` - 简单的编码解码 -- `epic` - 检查Epic Games喜加一优惠 -- `fadian` - fadian语录 -- `getstickers` - 下载整个贴纸包 -- `gif` - GIF与视频转贴纸 -- `git_PR` - Git PR 管理 -- `goodnight` - 自动统计晚安/早安 -- `gt` - 谷歌中英文互译 -- `his` - 查看被回复者最近消息 -- `hitokoto` - 获取随机一言 -- `httpcat` - 发送一张http状态码主题的猫猫图片 -- `ids` - 用户信息显示以及跳转链接 -- `im` - 图片监控插件 -- `ip` - IP 地址查询 -- `isalive` - 活了么 -- `javdb` - 寻找番号封面 -- `jupai` - 举牌小人 -- `keep_online` - 保活自动重启(测试版) 请查看说明操作 -- `keyword` - 关键词自动回复 -- `kitt` - 高级触发器: 匹配 -> 执行, 高度自定义, 逻辑自由 -- `kkp` - 获取NSFW视频 -- `komari` - Komari 服务器监控 -- `listusernames` - 列出属于自己的公开群组/频道 -- `lottery` - 抽奖 -- `lu_bs` - 鲁小迅整点报时 -- `manage_admin` - 管理管理员 -- `mode` - 自定义消息格式 -- `moyu` - 摸鱼日报 -- `music` - YouTube音乐 -- `music_bot` - 多音源音乐搜索 -- `music_hub` - 多音源音乐搜索下载插件 -- `netease` - 网易云音乐 -- `news` - 每日新闻 -- `nezha` - 哪吒监控 -- `ntp` - NTP 时间同步 -- `openlist` - openlist管理 -- `oxost` - 回复聊天中的文件与媒体 得到一个临时的下载链接 -- `pangu` - 消息自动pangu化 -- `paolu` - 群组一键跑路 -- `parsehub` - 社交媒体链接解析助手 -- `pic_to_sticker` - 图片转表情 -- `pmcaptcha` - 简单防私聊 -- `portball` - 临时禁言 -- `premium` - 群组大会员统计 -- `q` - 消息引用生成贴纸 -- `qr` - QR 二维码 -- `quote` - 引用贴纸生成 (本地版) -- `rate` - 货币实时汇率查询与计算 -- `restore_pin` - 恢复群组被取消的置顶消息 -- `rev` - 反转你的消息 -- `save` - 本地保存插件 -- `search` - 频道消息搜索 -- `sendat` - 定时消息发送插件 -- `service` - systemd服务状态查看 -- `shift` - 智能消息转发系统 -- `soutu` - soutu搜图 -- `speedlink` - 对其他服务器测速 -- `speedtest` - 网络速度测试 -- `ssh` - ssh管理 -- `sticker` - 偷表情 -- `sticker_to_pic` - 表情转图片 -- `sub` - substore简单管理 -- `subinfo` - 订阅链接信息查询 -- `sum` - 群消息总结 -- `t` - 文字转语音 -- `teletype` - 打字机效果 -- `tmp_admin` - 临时管理员 -- `trace` - 全局追踪点赞 -- `tts` - Azure文字转语音 -- `uai` - 引用消息 AI 分析 -- `warp` - warp管理 -- `weather` - 天气查询 -- `whois` - 域名查询 -- `xmsl` - 全自动羡慕 -- `yinglish` - 淫语翻译 -- `yt-dlp` - YouTube 视频下载 -- `yvlu` - 为被回复用户生成语录 -- `zhijiao` - 掷筊 强随机 使用 笅杯卦辞廿七句 -- `zpr` - 二次元图片 - -## 技术栈 - -- **开发语言**: TypeScript -- **数据库**: Lowdb -- **任务调度**: node-schedule -- **Telegram API**: Teleproto -- **图像处理**: Sharp -- **其他依赖**: axios, lodash 等 - - -## 贡献指南 - -欢迎提交新或改进现有。请确保: -1. 遵循 TypeScript 编码规范 -2. 包含完整的功能说明 -3. 添加适当的错误处理 -4. 更新 plugins.json 配置文件 - -## 声明 - -本仓库的表情素材等均来自网络,如有侵权请联系作者删除 - -## 许可证 - -本项目采用开源许可证,具体请查看各的许可证声明。 +- **`chatCache`**:`Map` +- **`CachedMsg`**:只存 id/senderId/date/text,省 80%+ 内存 +- 每个群最多 200 条消息(`CACHE_MSG_LIMIT`),新消息顶掉最旧的 +- **只缓存公开群**:onMsg 第一条消息调 `getEntity` 检查 username + className +- 缓存自动写盘(debounced 10s),**重启不丢** + +### 命令与缓存的关系 + +| 命令 | 数据来源 | API 调用 | +|------|----------|----------| +| cs | 遍历 `chatCache` | 零 | +| ds | 遍历 `chatCache` | 零 | +| sv | 实时监听 + lowdb 持久化 | 仅确认目标 | +| cache rebuild | 逐个拉取所有公开群 | 有 | + +### 手动重建 + +`.fbi cache rebuild` 逐个拉取公开群的最近 200 条消息(500~2000ms 随机间隔),写盘。仅在刚启动就需要 cs/ds 可用时使用。 + +## 持久化 + +- **LowDB**:`assets/fbi/db.json` +- 持久化内容:蹲守任务 + **消息缓存** +- 缓存自动落盘,**重启不丢** + +## 依赖 + +- `teleproto` — Telegram MTProto 层(TeleBox 内置) +- `lowdb@1.0.0` — JSON 持久化(TeleBox 内置) +- `@utils/*` — TeleBox 内部工具函数(pluginBase, globalClient 等) + - `createDirectoryInAssets` + - `safeGetReplyMessage` + - `getPrefixes` diff --git a/fbi/fbi.ts b/fbi/fbi.ts index 59a13b8..40a92a1 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -35,10 +35,6 @@ const htmlEsc = (s: string) => const peelChatId = (id: any) => String(typeof id === "bigint" ? id.toString() : id).replace(/^-100/, ""); -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 */ function rndDelay(min = 500, max = 2000): Promise { const ms = Math.floor(Math.random() * (max - min + 1)) + min; @@ -74,6 +70,10 @@ interface FbiDB { const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF, cache: {} }; +function stripMsg(m: Api.Message): CachedMsg { + return { id: m.id, senderId: Number(m.senderId), date: m.date, text: m.text || "" }; +} + class FbiPlugin extends Plugin { description = `FBI 跨群组追踪\n\n${HELP}`; @@ -136,9 +136,25 @@ class FbiPlugin extends Plugin { 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); + // paginated getDialogs — respect cacheLimit > 100 + const all: any[] = []; + let offsetId = 0; + let offsetDate = 0; + let offsetPeer: any = new Api.InputPeerEmpty(); + while (all.length < limit) { + const page = (await cl.getDialogs({ offsetId, offsetDate, offsetPeer, limit: 100 })) as any[]; + if (!page.length) break; + for (const d of page) { + if (d.isGroup || d.isChat) all.push(d); + if (all.length >= limit) break; + } + if (page.length < 100) break; // last page + const last = page[page.length - 1]; + offsetId = last.dialog?.topMessage?.id ?? 0; + offsetDate = last.dialog?.topMessage?.date ?? 0; + offsetPeer = last.dialog?.peer ?? new Api.InputPeerEmpty(); + } + const dialogs = all.slice(0, limit); let count = 0; for (const d of dialogs) { From c6527e43bdf78fc7083de517887fa55f5c184c9f Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:42:57 +0800 Subject: [PATCH 7/8] feat: split cache to separate file, add 30-day auto-prune + 24h cold sweep --- fbi/fbi.ts | 82 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/fbi/fbi.ts b/fbi/fbi.ts index 40a92a1..7d4d60a 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -62,13 +62,18 @@ interface SvEntry { triggerMsgId: number; } -interface FbiDB { +interface FbiConfig { surveillance: Record; cacheLimit: number; +} + +interface FbiCache { cache: Record; } -const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF, cache: {} }; +const CONFIG_DEF: FbiConfig = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF }; + +const CACHE_EXPIRE_SECS = 30 * 24 * 60 * 60; // 30 days in seconds function stripMsg(m: Api.Message): CachedMsg { return { id: m.id, senderId: Number(m.senderId), date: m.date, text: m.text || "" }; @@ -80,13 +85,22 @@ class FbiPlugin extends Plugin { cmdHandlers = { fbi: this.onCmd.bind(this) }; listenMessageHandler = this.onMsg.bind(this); - private db: any; + private configDb: any; + private cacheDb: any; private sv = new Map(); private chatCache = new Map(); private cacheReady = false; private cacheDirty = false; private cachePersistTimer: ReturnType | null = null; + /** remove messages older than 30 days from a chat cache, return true if any pruned */ + private pruneExpired(chat: CachedChat): boolean { + const cutoff = Math.floor(Date.now() / 1000) - CACHE_EXPIRE_SECS; + const before = chat.msgs.length; + chat.msgs = chat.msgs.filter(m => m.date > cutoff); + return chat.msgs.length !== before; + } + constructor() { super(); this.initDB().catch((e) => console.error("[fbi] initDB error", e)); @@ -96,25 +110,48 @@ class FbiPlugin extends Plugin { private async initDB() { const p = path.join(createDirectoryInAssets("fbi"), "db.json"); - this.db = await JSONFilePreset(p, DB_DEF); + this.configDb = await JSONFilePreset(p, CONFIG_DEF); // ensure cacheLimit has a default - if (typeof this.db.data.cacheLimit !== "number") { - this.db.data.cacheLimit = CACHE_LIMIT_DEF; - await this.db.write(); + if (typeof this.configDb.data.cacheLimit !== "number") { + this.configDb.data.cacheLimit = CACHE_LIMIT_DEF; + await this.configDb.write(); } - for (const [k, v] of Object.entries(this.db.data.surveillance)) this.sv.set(k, v); - // load persisted cache into memory - if (this.db.data.cache) { - for (const [k, v] of Object.entries(this.db.data.cache)) + for (const [k, v] of Object.entries(this.configDb.data.surveillance)) this.sv.set(k, v as SvEntry); + // load cache from separate file + const cp = path.join(createDirectoryInAssets("fbi"), "cache.json"); + this.cacheDb = await JSONFilePreset(cp, { cache: {} }); + if (this.cacheDb.data.cache) { + for (const [k, v] of Object.entries(this.cacheDb.data.cache)) this.chatCache.set(k, v as CachedChat); console.log(`[fbi] cache loaded: ${this.chatCache.size} groups`); } + + // migrate: if cache.json is empty but db.json has old cache field, move it over + if (this.chatCache.size === 0 && (this.configDb.data as any).cache) { + for (const [k, v] of Object.entries((this.configDb.data as any).cache)) + this.chatCache.set(k, v as CachedChat); + this.cacheDb.data.cache = Object.fromEntries(this.chatCache); + await this.cacheDb.write(); + delete (this.configDb.data as any).cache; + await this.configDb.write(); + console.log(`[fbi] migrated ${this.chatCache.size} groups from db.json to cache.json`); + } + this.cacheReady = true; + + // cold sweep every 24h — prune expired messages in silent groups + setInterval(() => { + if (!this.cacheReady) return; + let anyPruned = false; + for (const chat of this.chatCache.values()) + if (this.pruneExpired(chat)) anyPruned = true; + if (anyPruned) this.schedulePersistCache(); + }, 24 * 60 * 60 * 1000); } private async persistDb() { - this.db.data.surveillance = Object.fromEntries(this.sv); - await this.db.write(); + this.configDb.data.surveillance = Object.fromEntries(this.sv); + await this.configDb.write(); } /** debounced cache persist — at most once every 10s */ @@ -125,8 +162,8 @@ class FbiPlugin extends Plugin { this.cachePersistTimer = null; if (!this.cacheDirty) return; this.cacheDirty = false; - this.db.data.cache = Object.fromEntries(this.chatCache); - await this.db.write(); + this.cacheDb.data.cache = Object.fromEntries(this.chatCache); + await this.cacheDb.write(); }, 10_000); } @@ -135,7 +172,7 @@ class FbiPlugin extends Plugin { const cl = await getGlobalClient(); if (!cl) return; - const limit = this.db.data.cacheLimit; + const limit = this.configDb.data.cacheLimit; // paginated getDialogs — respect cacheLimit > 100 const all: any[] = []; let offsetId = 0; @@ -179,8 +216,8 @@ class FbiPlugin extends Plugin { } // persist to disk - this.db.data.cache = Object.fromEntries(this.chatCache); - await this.db.write(); + this.cacheDb.data.cache = Object.fromEntries(this.chatCache); + await this.cacheDb.write(); this.cacheReady = true; console.log(`[fbi] cache ready: ${count} groups (limit ${limit})`); } @@ -326,6 +363,7 @@ class FbiPlugin extends Plugin { } if (chat) { chat.msgs.unshift(stripMsg(msg)); + this.pruneExpired(chat); if (chat.msgs.length > CACHE_MSG_LIMIT) chat.msgs.length = CACHE_MSG_LIMIT; this.schedulePersistCache(); } @@ -455,13 +493,13 @@ class FbiPlugin extends Plugin { 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}`, + text: `❌ 缓存上限必须为 ${CACHE_LIMIT_MIN}~${CACHE_LIMIT_MAX} 之间的整数。当前:${this.configDb.data.cacheLimit}`, parseMode: "html", }); return; } - this.db.data.cacheLimit = n; - await this.db.write(); + this.configDb.data.cacheLimit = n; + await this.configDb.write(); await msg.edit({ text: `✅ 缓存上限已设为 ${n} 个群组。使用 ${PREFIX}fbi cache rebuild 重新构建。`, parseMode: "html", @@ -482,7 +520,7 @@ class FbiPlugin extends Plugin { } // status - const limit = this.db.data.cacheLimit; + const limit = this.configDb.data.cacheLimit; await msg.edit({ text: `📦 FBI 缓存状态 From 4bb33a8d03d187af8921e5a0ac2a9bc023cee1b4 Mon Sep 17 00:00:00 2001 From: xiaochengshiguduo <114392164+xiaochengshiguduo@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:26:10 +0800 Subject: [PATCH 8/8] =?UTF-8?q?sync=20fbi.ts:=20200=E2=86=923000=20limit,?= =?UTF-8?q?=201-10s=20rebuild=20delay,=20buildCache=20finally=20guard,=20d?= =?UTF-8?q?ocs=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fbi/fbi.ts | 112 +++++++++++++++++++++++++---------------------------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/fbi/fbi.ts b/fbi/fbi.ts index 7d4d60a..a8d6c50 100644 --- a/fbi/fbi.ts +++ b/fbi/fbi.ts @@ -19,7 +19,7 @@ const HELP = `🕵️ FBI 跨群组追踪 目标可为 @用户名、用户ID,或回复消息自动取被回复者。`; -const CACHE_MSG_LIMIT = 200; // max messages stored per group +const CACHE_MSG_LIMIT = 3000; // 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; @@ -36,7 +36,7 @@ const htmlEsc = (s: string) => const peelChatId = (id: any) => String(typeof id === "bigint" ? id.toString() : id).replace(/^-100/, ""); /** random delay between min and max ms */ -function rndDelay(min = 500, max = 2000): Promise { +function rndDelay(min: number, max: number): Promise { const ms = Math.floor(Math.random() * (max - min + 1)) + min; return new Promise((r) => setTimeout(r, ms)); } @@ -126,17 +126,6 @@ class FbiPlugin extends Plugin { console.log(`[fbi] cache loaded: ${this.chatCache.size} groups`); } - // migrate: if cache.json is empty but db.json has old cache field, move it over - if (this.chatCache.size === 0 && (this.configDb.data as any).cache) { - for (const [k, v] of Object.entries((this.configDb.data as any).cache)) - this.chatCache.set(k, v as CachedChat); - this.cacheDb.data.cache = Object.fromEntries(this.chatCache); - await this.cacheDb.write(); - delete (this.configDb.data as any).cache; - await this.configDb.write(); - console.log(`[fbi] migrated ${this.chatCache.size} groups from db.json to cache.json`); - } - this.cacheReady = true; // cold sweep every 24h — prune expired messages in silent groups @@ -169,57 +158,60 @@ class FbiPlugin extends Plugin { /** scan public groups one-by-one with random delay, fill chatCache */ private async buildCache() { - const cl = await getGlobalClient(); - if (!cl) return; - - const limit = this.configDb.data.cacheLimit; - // paginated getDialogs — respect cacheLimit > 100 - const all: any[] = []; - let offsetId = 0; - let offsetDate = 0; - let offsetPeer: any = new Api.InputPeerEmpty(); - while (all.length < limit) { - const page = (await cl.getDialogs({ offsetId, offsetDate, offsetPeer, limit: 100 })) as any[]; - if (!page.length) break; - for (const d of page) { - if (d.isGroup || d.isChat) all.push(d); - if (all.length >= limit) break; + try { + const cl = await getGlobalClient(); + if (!cl) return; + + const limit = this.configDb.data.cacheLimit; + // paginated getDialogs — respect cacheLimit > 100 + const all: any[] = []; + let offsetId = 0; + let offsetDate = 0; + let offsetPeer: any = new Api.InputPeerEmpty(); + while (all.length < limit) { + const page = (await cl.getDialogs({ offsetId, offsetDate, offsetPeer, limit: 100 })) as any[]; + if (!page.length) break; + for (const d of page) { + if (d.isGroup || d.isChat) all.push(d); + if (all.length >= limit) break; + } + if (page.length < 100) break; // last page + const last = page[page.length - 1]; + offsetId = last.dialog?.topMessage?.id ?? 0; + offsetDate = last.dialog?.topMessage?.date ?? 0; + offsetPeer = last.dialog?.peer ?? new Api.InputPeerEmpty(); } - if (page.length < 100) break; // last page - const last = page[page.length - 1]; - offsetId = last.dialog?.topMessage?.id ?? 0; - offsetDate = last.dialog?.topMessage?.date ?? 0; - offsetPeer = last.dialog?.peer ?? new Api.InputPeerEmpty(); - } - const dialogs = all.slice(0, limit); + const dialogs = all.slice(0, limit); - let count = 0; - for (const d of dialogs) { - const msgs: CachedMsg[] = []; - for await (const m of cl.iterMessages(d.id, { limit: CACHE_MSG_LIMIT })) { - msgs.push(stripMsg(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 */ } - // ponytail: skip private groups (no username) - if (username) { - this.chatCache.set(String(d.id), { username, title, msgs }); - count++; + let count = 0; + for (const d of dialogs) { + const msgs: CachedMsg[] = []; + for await (const m of cl.iterMessages(d.id, { limit: CACHE_MSG_LIMIT })) { + msgs.push(stripMsg(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 */ } + // ponytail: skip private groups (no username) + if (username) { + this.chatCache.set(String(d.id), { username, title, msgs }); + count++; + } + await rndDelay(1000, 10000); } - await rndDelay(); - } - // persist to disk - this.cacheDb.data.cache = Object.fromEntries(this.chatCache); - await this.cacheDb.write(); - this.cacheReady = true; - console.log(`[fbi] cache ready: ${count} groups (limit ${limit})`); + // persist to disk + this.cacheDb.data.cache = Object.fromEntries(this.chatCache); + await this.cacheDb.write(); + console.log(`[fbi] cache ready: ${count} groups (limit ${limit})`); + } finally { + this.cacheReady = true; + } } /* ====== router ====== */