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 new file mode 100644 index 0000000..a8d6c50 --- /dev/null +++ b/fbi/fbi.ts @@ -0,0 +1,540 @@ +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 = 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; + +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/, ""); + +/** random delay between min and max ms */ +function rndDelay(min: number, max: number): 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 FbiConfig { + surveillance: Record; + cacheLimit: number; +} + +interface FbiCache { + cache: Record; +} + +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 || "" }; +} + +class FbiPlugin extends Plugin { + description = `FBI 跨群组追踪\n\n${HELP}`; + + cmdHandlers = { fbi: this.onCmd.bind(this) }; + listenMessageHandler = this.onMsg.bind(this); + + 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)); + } + + /* ====== bootstrap ====== */ + + private async initDB() { + const p = path.join(createDirectoryInAssets("fbi"), "db.json"); + this.configDb = await JSONFilePreset(p, CONFIG_DEF); + // ensure cacheLimit has a default + 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.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`); + } + + 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.configDb.data.surveillance = Object.fromEntries(this.sv); + await this.configDb.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.cacheDb.data.cache = Object.fromEntries(this.chatCache); + await this.cacheDb.write(); + }, 10_000); + } + + /** scan public groups one-by-one with random delay, fill chatCache */ + private async buildCache() { + 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(); + } + 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++; + } + await rndDelay(1000, 10000); + } + + // 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 ====== */ + + 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)); + this.pruneExpired(chat); + 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.configDb.data.cacheLimit}`, + parseMode: "html", + }); + return; + } + this.configDb.data.cacheLimit = n; + await this.configDb.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.configDb.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语录"