diff --git a/eatgif/eatgif.ts b/eatgif/eatgif.ts index c6286ae4..6101ccc2 100644 --- a/eatgif/eatgif.ts +++ b/eatgif/eatgif.ts @@ -72,7 +72,12 @@ const help_text = `🧩 头像动图表情 用法: ${commandName} [list|ls|clear|名称]空/ list:查看表情列表 -• 生成:回复目标并输入名称`; +• 生成:回复目标并输入名称 + +指定用户: +• ${commandName} 名称 @A @B - A 对 B +• ${commandName} 名称 @B (回复A) - A 对 B +• ${commandName} 名称 (回复B) - 自己对 B`; const htmlEscape = (text: string): string => String(text || "").replace( @@ -130,6 +135,9 @@ class EatGifPlugin extends Plugin { const [, ...args] = parts; const sub = (args[0] || "").toLowerCase(); + // 提取 @用户名参数 + const mentionedUsers = args.slice(1).filter((arg) => arg.startsWith("@")); + try { await ensureConfig(); @@ -156,9 +164,19 @@ class EatGifPlugin extends Plugin { return; } - if (!msg.isReply && !trigger?.isReply) { + // 检查是否有足够的用户信息 + const hasReply = msg.isReply || trigger?.isReply; + const hasTwoMentions = mentionedUsers.length >= 2; + const hasOneMention = mentionedUsers.length === 1; + + if (!hasReply && !hasTwoMentions) { await msg.edit({ - text: `💡 请先回复一个用户的消息再执行\n\n使用:${commandName} list 查看表情列表`, + text: `💡 请指定两个用户或回复一个用户的消息 + +用法: +• ${commandName} ${sub} @A @B - A 对 B +• ${commandName} ${sub} @B (回复A) - A 对 B +• ${commandName} ${sub} (回复B) - 自己对 B`, parseMode: "html", }); return; @@ -168,7 +186,7 @@ class EatGifPlugin extends Plugin { text: `⏳ 正在生成 ${htmlEscape(config[sub].desc)}...`, parseMode: "html", }); - await this.generateGif(sub, { msg, trigger }); + await this.generateGif(sub, { msg, trigger, mentionedUsers }); } catch (e: any) { await msg.edit({ text: `❌ 失败:${htmlEscape(e?.message || String(e))}`, @@ -200,21 +218,40 @@ class EatGifPlugin extends Plugin { private async generateGif( gifName: string, - params: { msg: Api.Message; trigger?: Api.Message } + params: { msg: Api.Message; trigger?: Api.Message; mentionedUsers?: string[] } ) { - const { msg, trigger } = params; + const { msg, trigger, mentionedUsers = [] } = params; const gifConfig = await loadGifDetailConfig(config[gifName].url); - // 由于要生成很多张图片,最好就是保存 self.avatar 以及 you.avatar 不断调用 - const meAvatarBuffer = await this.getSelfAvatarBuffer(msg, trigger); + // 获取头像的逻辑: + // 1. .eatgif kiss @A @B -> A 对 B + // 2. 回复A + .eatgif kiss @B -> A 对 B + // 3. 回复B + .eatgif kiss -> 自己对 B + + let meAvatarBuffer: Buffer | undefined; + let youAvatarBuffer: Buffer | undefined; + + if (mentionedUsers.length >= 2) { + // 情况1: 指定了两个用户 @A @B + meAvatarBuffer = await this.getAvatarByUsername(msg, mentionedUsers[0]); + youAvatarBuffer = await this.getAvatarByUsername(msg, mentionedUsers[1]); + } else if (mentionedUsers.length === 1) { + // 情况2: 回复A + 指定@B + meAvatarBuffer = await this.getReplyUserAvatarBuffer(msg, trigger); + youAvatarBuffer = await this.getAvatarByUsername(msg, mentionedUsers[0]); + } else { + // 情况3: 回复B(原有逻辑) + meAvatarBuffer = await this.getSelfAvatarBuffer(msg, trigger); + youAvatarBuffer = await this.getReplyUserAvatarBuffer(msg, trigger); + } + if (!meAvatarBuffer) { - await msg.edit({ text: "无法获取自己的头像" }); + await msg.edit({ text: "❌ 无法获取用户A的头像", parseMode: "html" }); await msg.deleteWithDelay(2000); return; } - const youAvatarBuffer = await this.getYouAvatarBuffer(msg, trigger); if (!youAvatarBuffer) { - await msg.edit({ text: "无法获取对方的头像" }); + await msg.edit({ text: "❌ 无法获取用户B的头像", parseMode: "html" }); await msg.deleteWithDelay(2000); return; } @@ -373,7 +410,7 @@ class EatGifPlugin extends Plugin { left: role.x, }; } - // 获取头像等数据 + // 获取自己的头像 private async getSelfAvatarBuffer( msg: Api.Message, trigger?: Api.Message @@ -385,10 +422,15 @@ class EatGifPlugin extends Plugin { const meAvatarBuffer = (await msg.client?.downloadProfilePhoto(meId, { isBig: false, })) as Buffer | undefined; + // 检查 buffer 是否有效 + if (!meAvatarBuffer || meAvatarBuffer.length === 0) { + return await this.generateDefaultAvatar("Me"); + } return meAvatarBuffer; } - private async getYouAvatarBuffer( + // 获取被回复用户的头像 + private async getReplyUserAvatarBuffer( msg: Api.Message, trigger?: Api.Message ): Promise { @@ -397,13 +439,77 @@ class EatGifPlugin extends Plugin { replyTo = await trigger?.getReplyMessage(); } if (!replyTo?.senderId) return; - const youAvatarBuffer = await msg.client?.downloadProfilePhoto( + const avatarBuffer = await msg.client?.downloadProfilePhoto( replyTo?.senderId, { isBig: false, } ); - return youAvatarBuffer as Buffer | undefined; + // 检查 buffer 是否有效 + if (!avatarBuffer || (avatarBuffer as Buffer).length === 0) { + // 尝试获取用户名生成默认头像 + const sender = replyTo.sender as any; + const name = sender?.firstName || sender?.username || "User"; + return await this.generateDefaultAvatar(name); + } + return avatarBuffer as Buffer | undefined; + } + + // 通过用户名获取头像 + private async getAvatarByUsername( + msg: Api.Message, + username: string + ): Promise { + try { + // 移除 @ 前缀 + const cleanUsername = username.startsWith("@") ? username.slice(1) : username; + const entity = await msg.client?.getEntity(cleanUsername); + if (!entity) return; + const avatarBuffer = await msg.client?.downloadProfilePhoto(entity, { + isBig: false, + }); + // 检查 buffer 是否有效 + if (!avatarBuffer || (avatarBuffer as Buffer).length === 0) { + // 用户没有头像,生成默认头像 + return await this.generateDefaultAvatar(cleanUsername); + } + return avatarBuffer as Buffer | undefined; + } catch (e) { + console.log(`获取用户 ${username} 头像失败:`, e); + return; + } + } + + // 生成默认头像(当用户没有设置头像时) + private async generateDefaultAvatar(name: string): Promise { + // 根据名字生成一个颜色 + const colors = [ + "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", + "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", + "#BB8FCE", "#85C1E9", "#F8B500", "#00CED1" + ]; + const colorIndex = name.charCodeAt(0) % colors.length; + const bgColor = colors[colorIndex]; + + // 获取首字母 + const initial = name.charAt(0).toUpperCase(); + + // 使用 sharp 生成一个带首字母的圆形头像 + const size = 200; + const svg = ` + + + + ${initial} + + + `; + + return await sharp(Buffer.from(svg)) + .resize(size, size) + .png() + .toBuffer(); } private async getMediaAvatarBuffer( diff --git a/plugins.json b/plugins.json index d0b42f01..32a8decf 100644 --- a/plugins.json +++ b/plugins.json @@ -371,6 +371,10 @@ "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/sum/sum.ts?raw=true", "desc": "群消息总结" }, + "stat": { + "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/stat/stat.ts?raw=true", + "desc": "Telegram账号统计" + }, "t": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/t/t.ts?raw=true", "desc": "文字转语音" diff --git a/stat/stat.ts b/stat/stat.ts new file mode 100644 index 00000000..a8c136d4 --- /dev/null +++ b/stat/stat.ts @@ -0,0 +1,535 @@ +import { Plugin } from "@utils/pluginBase"; +import { getGlobalClient } from "@utils/globalClient"; +import { getPrefixes } from "@utils/pluginManager"; +import { Api } from "telegram"; +import * as path from "path"; +import * as fs from "fs"; + +// 获取命令前缀 +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; + +// 帮助文本 +const help_text = `📊 Telegram 账号统计插件 + +📝 功能描述: +• 统计账号加入的群组、频道、机器人、私聊 +• 按类型和状态分类统计 +• 支持导出为 TXT 或 JSON 文件 + +🔧 使用方法: +• ${mainPrefix}stat - 显示统计概览 +• ${mainPrefix}stat list - 显示详细分类列表 +• ${mainPrefix}stat export txt - 导出为 TXT 文件 +• ${mainPrefix}stat export json - 导出为 JSON 文件 + +📊 统计维度: +• 公开群组 / 私有群组 +• 公开频道 / 私有频道 +• 机器人对话 / 用户私聊 +• 静音 / 归档 / 未读状态`; + +// 对话信息接口 +interface DialogInfo { + id: string; + title: string; + username: string | null; + unreadCount: number; + isMuted: boolean; + isArchived: boolean; + link: string; // 跳转链接 + type: "user" | "bot" | "group" | "channel"; +} + +// 分类统计结果接口 +interface StatResult { + publicGroups: DialogInfo[]; + privateGroups: DialogInfo[]; + publicChannels: DialogInfo[]; + privateChannels: DialogInfo[]; + bots: DialogInfo[]; + users: DialogInfo[]; + // 状态统计 + mutedCount: number; + archivedCount: number; + unreadDialogs: number; +} + +class StatPlugin extends Plugin { + description: string = `Telegram 账号统计插件\n\n${help_text}`; + + cmdHandlers: Record Promise> = { + stat: async (msg: Api.Message) => { + const client = await getGlobalClient(); + if (!client) { + await msg.edit({ text: "❌ 客户端未初始化", parseMode: "html" }); + return; + } + + // 解析参数 + const text = msg.text?.trim() || ""; + const parts = text.split(/\s+/); + const subCmd = parts[1]?.toLowerCase() || ""; + const subArg = parts[2]?.toLowerCase() || ""; + + try { + // 帮助命令 + if (subCmd === "help" || subCmd === "h") { + await msg.edit({ text: help_text, parseMode: "html" }); + return; + } + + // 显示处理中 + await msg.edit({ + text: "🔄 正在获取对话列表...", + parseMode: "html" + }); + + // 获取统计数据 + const stat = await this.getDialogStats(client); + + // 根据子命令处理 + if (subCmd === "list") { + await this.showDetailList(msg, stat); + } else if (subCmd === "export") { + await this.exportData(msg, stat, subArg); + } else { + await this.showOverview(msg, stat); + } + + } catch (error: any) { + console.error("[stat] 插件执行失败:", error); + await msg.edit({ + text: `❌ 统计失败: ${error.message || "未知错误"}`, + parseMode: "html" + }); + } + } + }; + + // 获取对话统计数据 + private async getDialogStats(client: any): Promise { + const result: StatResult = { + publicGroups: [], + privateGroups: [], + publicChannels: [], + privateChannels: [], + bots: [], + users: [], + mutedCount: 0, + archivedCount: 0, + unreadDialogs: 0 + }; + + // 获取所有对话 + const dialogs = await client.getDialogs({ limit: undefined }); + + for (const dialog of dialogs) { + const entity = dialog.entity; + if (!entity) continue; + + const entityId = entity.id?.toString() || "unknown"; + + // 提取对话信息 + const info: DialogInfo = { + id: entityId, + title: this.getDialogTitle(entity), + username: entity.username || null, + unreadCount: dialog.unreadCount || 0, + isMuted: this.isMuted(dialog), + isArchived: dialog.archived || false, + link: "", + type: "user" + }; + + // 按类型分类并生成链接 + if (entity.className === "Channel") { + if (entity.broadcast) { + // 频道 + info.type = "channel"; + info.link = this.getChannelLink(entity); + if (entity.username) { + result.publicChannels.push(info); + } else { + result.privateChannels.push(info); + } + } else { + // 超级群组 + info.type = "group"; + info.link = this.getChannelLink(entity); + if (entity.username) { + result.publicGroups.push(info); + } else { + result.privateGroups.push(info); + } + } + } else if (entity.className === "Chat") { + // 普通群组(都是私有的) + info.type = "group"; + info.link = `tg://openmessage?chat_id=${entityId}`; + result.privateGroups.push(info); + } else if (entity.className === "User") { + if (entity.bot) { + info.type = "bot"; + info.link = this.getUserLink(entity); + result.bots.push(info); + } else { + info.type = "user"; + info.link = this.getUserLink(entity); + result.users.push(info); + } + } + + // 统计状态 + if (info.isMuted) result.mutedCount++; + if (info.isArchived) result.archivedCount++; + if (info.unreadCount > 0) result.unreadDialogs++; + } + + return result; + } + + // 获取对话标题 + private getDialogTitle(entity: any): string { + if (entity.title) return entity.title; + if (entity.firstName) { + return entity.lastName + ? `${entity.firstName} ${entity.lastName}` + : entity.firstName; + } + if (entity.username) return `@${entity.username}`; + return `ID: ${entity.id}`; + } + + // 生成用户链接 + private getUserLink(entity: any): string { + if (entity.username) { + return `https://t.me/${entity.username}`; + } + return `tg://user?id=${entity.id}`; + } + + // 生成频道/群组链接 + private getChannelLink(entity: any): string { + if (entity.username) { + return `https://t.me/${entity.username}`; + } + // 私有频道/群组使用 c/ 格式 + return `https://t.me/c/${entity.id}/1`; + } + + // 判断是否静音 + private isMuted(dialog: any): boolean { + try { + const settings = dialog.notifySettings; + if (!settings) return false; + // muteUntil > 0 表示静音 + return settings.muteUntil > 0 || settings.silent === true; + } catch { + return false; + } + } + + // 显示统计概览 + private async showOverview(msg: Api.Message, stat: StatResult): Promise { + const totalGroups = stat.publicGroups.length + stat.privateGroups.length; + const totalChannels = stat.publicChannels.length + stat.privateChannels.length; + const total = totalGroups + totalChannels + stat.bots.length + stat.users.length; + + const text = `📊 Telegram 账号统计 + +👥 群组: ${totalGroups} 个 + ├ 公开群组: ${stat.publicGroups.length} 个 + └ 私有群组: ${stat.privateGroups.length} 个 + +📢 频道: ${totalChannels} 个 + ├ 公开频道: ${stat.publicChannels.length} 个 + └ 私有频道: ${stat.privateChannels.length} 个 + +🤖 机器人: ${stat.bots.length} 个 +👤 私聊: ${stat.users.length} 个 + +📌 状态统计: + ├ 已静音: ${stat.mutedCount} 个 + ├ 已归档: ${stat.archivedCount} 个 + └ 未读对话: ${stat.unreadDialogs} 个 + +📈 总计: ${total} 个对话 + +💡 使用 ${mainPrefix}stat list 查看详细列表 +💡 使用 ${mainPrefix}stat export txt/json 导出数据`; + + await msg.edit({ text, parseMode: "html" }); + } + + // 显示详细列表 + private async showDetailList(msg: Api.Message, stat: StatResult): Promise { + let text = `📊 Telegram 对话详细列表\n`; + + // 公开群组 + if (stat.publicGroups.length > 0) { + text += `\n👥 公开群组 (${stat.publicGroups.length})\n`; + text += this.formatDialogList(stat.publicGroups.slice(0, 10)); + if (stat.publicGroups.length > 10) { + text += ` ... 还有 ${stat.publicGroups.length - 10} 个\n`; + } + } + + // 私有群组 + if (stat.privateGroups.length > 0) { + text += `\n🔒 私有群组 (${stat.privateGroups.length})\n`; + text += this.formatDialogList(stat.privateGroups.slice(0, 10)); + if (stat.privateGroups.length > 10) { + text += ` ... 还有 ${stat.privateGroups.length - 10} 个\n`; + } + } + + // 公开频道 + if (stat.publicChannels.length > 0) { + text += `\n📢 公开频道 (${stat.publicChannels.length})\n`; + text += this.formatDialogList(stat.publicChannels.slice(0, 10)); + if (stat.publicChannels.length > 10) { + text += ` ... 还有 ${stat.publicChannels.length - 10} 个\n`; + } + } + + // 私有频道 + if (stat.privateChannels.length > 0) { + text += `\n🔐 私有频道 (${stat.privateChannels.length})\n`; + text += this.formatDialogList(stat.privateChannels.slice(0, 10)); + if (stat.privateChannels.length > 10) { + text += ` ... 还有 ${stat.privateChannels.length - 10} 个\n`; + } + } + + // 机器人 + if (stat.bots.length > 0) { + text += `\n🤖 机器人 (${stat.bots.length})\n`; + text += this.formatDialogList(stat.bots.slice(0, 10)); + if (stat.bots.length > 10) { + text += ` ... 还有 ${stat.bots.length - 10} 个\n`; + } + } + + // 用户私聊 + if (stat.users.length > 0) { + text += `\n👤 用户私聊 (${stat.users.length})\n`; + text += this.formatDialogList(stat.users.slice(0, 10)); + if (stat.users.length > 10) { + text += ` ... 还有 ${stat.users.length - 10} 个\n`; + } + } + + text += `\n💡 使用 ${mainPrefix}stat export txt 导出完整列表`; + + // 检查消息长度 + if (text.length > 4096) { + text = text.substring(0, 4000) + `\n\n... 内容过长,请使用导出功能查看完整列表`; + } + + await msg.edit({ text, parseMode: "html" }); + } + + // 格式化对话列表 + private formatDialogList(dialogs: DialogInfo[]): string { + let text = ""; + for (const d of dialogs) { + const status = []; + if (d.isMuted) status.push("🔇"); + if (d.isArchived) status.push("📁"); + if (d.unreadCount > 0) status.push(`💬${d.unreadCount}`); + + const statusStr = status.length > 0 ? ` ${status.join(" ")}` : ""; + const usernameStr = d.username ? ` (@${d.username})` : ""; + + text += ` • ${this.escapeHtml(d.title)}${usernameStr}${statusStr}\n`; + } + return text; + } + + // 导出数据 + private async exportData(msg: Api.Message, stat: StatResult, format: string): Promise { + const client = await getGlobalClient(); + if (!client) return; + + if (format !== "txt" && format !== "json") { + await msg.edit({ + text: `❌ 不支持的格式: ${format}\n\n💡 支持的格式: txt, json`, + parseMode: "html" + }); + return; + } + + await msg.edit({ + text: "📤 正在生成导出文件...", + parseMode: "html" + }); + + const timestamp = new Date().toISOString().slice(0, 10); + const tempDir = path.join(process.cwd(), "temp"); + + // 确保临时目录存在 + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + let filePath: string; + let content: string; + + if (format === "json") { + filePath = path.join(tempDir, `telegram_stat_${timestamp}.json`); + content = this.generateJson(stat); + } else { + filePath = path.join(tempDir, `telegram_stat_${timestamp}.txt`); + content = this.generateTxt(stat); + } + + // 写入文件 + fs.writeFileSync(filePath, content, "utf-8"); + + // 发送文件 + try { + await client.sendFile(msg.chatId, { + file: filePath, + caption: `📊 Telegram 账号统计导出\n\n📅 导出时间: ${timestamp}\n📄 格式: ${format.toUpperCase()}`, + parseMode: "html" + }); + + await msg.edit({ + text: `✅ 导出成功\n\n📄 文件已发送`, + parseMode: "html" + }); + } finally { + // 清理临时文件 + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } + } + + // 生成 JSON 内容 + private generateJson(stat: StatResult): string { + const data = { + exportTime: new Date().toISOString(), + summary: { + totalGroups: stat.publicGroups.length + stat.privateGroups.length, + totalChannels: stat.publicChannels.length + stat.privateChannels.length, + totalBots: stat.bots.length, + totalUsers: stat.users.length, + mutedCount: stat.mutedCount, + archivedCount: stat.archivedCount, + unreadDialogs: stat.unreadDialogs + }, + dialogs: { + publicGroups: stat.publicGroups, + privateGroups: stat.privateGroups, + publicChannels: stat.publicChannels, + privateChannels: stat.privateChannels, + bots: stat.bots, + users: stat.users + } + }; + return JSON.stringify(data, null, 2); + } + + // 生成 TXT 内容 + private generateTxt(stat: StatResult): string { + const totalGroups = stat.publicGroups.length + stat.privateGroups.length; + const totalChannels = stat.publicChannels.length + stat.privateChannels.length; + const total = totalGroups + totalChannels + stat.bots.length + stat.users.length; + + let text = `Telegram 账号统计报告 +导出时间: ${new Date().toLocaleString("zh-CN")} +${"=".repeat(50)} + +【统计概览】 +群组总数: ${totalGroups} 个 + - 公开群组: ${stat.publicGroups.length} 个 + - 私有群组: ${stat.privateGroups.length} 个 + +频道总数: ${totalChannels} 个 + - 公开频道: ${stat.publicChannels.length} 个 + - 私有频道: ${stat.privateChannels.length} 个 + +机器人: ${stat.bots.length} 个 +私聊: ${stat.users.length} 个 + +状态统计: + - 已静音: ${stat.mutedCount} 个 + - 已归档: ${stat.archivedCount} 个 + - 未读对话: ${stat.unreadDialogs} 个 + +总计: ${total} 个对话 + +${"=".repeat(50)} +【详细列表】 +`; + + // 公开群组 + if (stat.publicGroups.length > 0) { + text += `\n[公开群组 - ${stat.publicGroups.length} 个]\n`; + text += this.formatTxtList(stat.publicGroups); + } + + // 私有群组 + if (stat.privateGroups.length > 0) { + text += `\n[私有群组 - ${stat.privateGroups.length} 个]\n`; + text += this.formatTxtList(stat.privateGroups); + } + + // 公开频道 + if (stat.publicChannels.length > 0) { + text += `\n[公开频道 - ${stat.publicChannels.length} 个]\n`; + text += this.formatTxtList(stat.publicChannels); + } + + // 私有频道 + if (stat.privateChannels.length > 0) { + text += `\n[私有频道 - ${stat.privateChannels.length} 个]\n`; + text += this.formatTxtList(stat.privateChannels); + } + + // 机器人 + if (stat.bots.length > 0) { + text += `\n[机器人 - ${stat.bots.length} 个]\n`; + text += this.formatTxtList(stat.bots); + } + + // 用户私聊 + if (stat.users.length > 0) { + text += `\n[用户私聊 - ${stat.users.length} 个]\n`; + text += this.formatTxtList(stat.users); + } + + return text; + } + + // 格式化 TXT 列表 + private formatTxtList(dialogs: DialogInfo[]): string { + let text = ""; + for (const d of dialogs) { + const status = []; + if (d.isMuted) status.push("静音"); + if (d.isArchived) status.push("归档"); + if (d.unreadCount > 0) status.push(`${d.unreadCount}条未读`); + + const statusStr = status.length > 0 ? ` [${status.join(", ")}]` : ""; + const usernameStr = d.username ? ` (@${d.username})` : ""; + + text += ` - ${d.title}${usernameStr}\n`; + text += ` ID: ${d.id}${statusStr}\n`; + text += ` 链接: ${d.link}\n`; + } + return text; + } + + // HTML 转义 + private escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, m => ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + }[m] || m)); + } +} + +export default new StatPlugin();