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 ====== */