diff --git a/fbi/fbi.ts b/fbi/fbi.ts
new file mode 100644
index 0000000..59a13b8
--- /dev/null
+++ b/fbi/fbi.ts
@@ -0,0 +1,494 @@
+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 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;
+ 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: CachedMsg[];
+}
+
+interface SvEntry {
+ targetId: string;
+ targetName: string;
+ triggerPeer: string;
+ triggerMsgId: number;
+}
+
+interface FbiDB {
+ surveillance: Record;
+ cacheLimit: number;
+ cache: Record;
+}
+
+const DB_DEF: FbiDB = { surveillance: {}, cacheLimit: CACHE_LIMIT_DEF, cache: {} };
+
+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;
+ private cacheDirty = false;
+ private cachePersistTimer: ReturnType | null = null;
+
+ 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);
+ // 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;
+ }
+
+ private async persistDb() {
+ this.db.data.surveillance = Object.fromEntries(this.sv);
+ 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();
+ 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: 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();
+ }
+
+ // 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})`);
+ }
+
+ /* ====== 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;
+ }
+
+ /* ====== 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: CachedMsg; 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;
+ }
+
+ // 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要想人不知除非己莫为。`);
+ }
+
+ /* ====== 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(stripMsg(msg));
+ if (chat.msgs.length > CACHE_MSG_LIMIT) chat.msgs.length = CACHE_MSG_LIMIT;
+ this.schedulePersistCache();
+ }
+ }
+
+ // 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;
+
+ // 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天网恢恢疏而不漏。`;
+
+ // 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: CachedMsg | null = null;
+
+ for (const [peer, chat] of this.chatCache) {
+ let cnt = 0;
+ let latest: CachedMsg | 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;
+ }
+
+ // 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..fd93055 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/TeleBoxOrg/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语录"