From 58fd12fd43d430bd64bffed99aaf42ec0db8b632 Mon Sep 17 00:00:00 2001 From: TiaraBasori <131457234+TiaraBasori@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:18:53 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=86=20prometheus=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=9B=B4=E5=90=8D=E4=B8=BA=20save=20=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- plugins.json | 8 +- prometheus/prometheus.ts | 875 ----------------------- save/save.ts | 1466 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1470 insertions(+), 879 deletions(-) delete mode 100644 prometheus/prometheus.ts create mode 100644 save/save.ts diff --git a/plugins.json b/plugins.json index 11d54bee..e874fc11 100644 --- a/plugins.json +++ b/plugins.json @@ -311,10 +311,6 @@ "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/premium/premium.ts?raw=true", "desc": "群组大会员统计" }, - "prometheus": { - "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/prometheus/prometheus.ts?raw=true", - "desc": "突破Telegram保存限制" - }, "q": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/q/q.ts?raw=true", "desc": "消息引用生成贴纸" @@ -335,6 +331,10 @@ "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/rev/rev.ts?raw=true", "desc": "反转你的消息" }, + "save": { + "url": "https://github.com/TiaraBasori/TeleBox_Plugins/blob/main/save/save.ts?raw=true", + "desc": "本地保存插件" + }, "search": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/search/search.ts?raw=true", "desc": "频道消息搜索" diff --git a/prometheus/prometheus.ts b/prometheus/prometheus.ts deleted file mode 100644 index 20627e8a..00000000 --- a/prometheus/prometheus.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { Plugin } from "@utils/pluginBase"; -import { getPrefixes } from "@utils/pluginManager"; -import { Api } from "teleproto"; -import { getGlobalClient } from "@utils/globalClient"; -import { createDirectoryInAssets, createDirectoryInTemp } from "@utils/pathHelpers"; -import { JSONFilePreset } from "lowdb/node"; -import * as path from "path"; -import * as fs from "fs/promises"; -import { statSync, existsSync } from "fs"; -import { CustomFile } from 'teleproto/client/uploads'; - -const prefixes = getPrefixes(); -const mainPrefix = prefixes[0]; - - -const htmlEscape = (text: string): string => - text.replace(/[&<>"']/g, m => ({ - '&': '&', '<': '<', '>': '>', - '"': '"', "'": ''' - }[m] || m)); - -interface UserConfig { - target: string; - showSource: boolean; // 新增:来源显示配置 -} - -interface PrometheusDB { - users: Record; -} - -const help_text = `🔥Prometheus -突破Telegram保存限制 - -
"To defy Power, which seems omnipotent." -—Percy Bysshe Shelley, Prometheus Unbound
- -📝 功能: -• 突破"限制保存内容",转发任何消息 -• 支持批量处理多个消息链接 -• 支持范围保存功能(自动保存指定范围内的所有消息) -• 支持来源显示功能 -• 同时支持${mainPrefix}prometheus${mainPrefix}pms - -🔧 使用方法: - -设置默认目标: -• ${mainPrefix}pms to [目标] - 设置默认转发目标(支持用户名、chatid如-123456780、'me') -• ${mainPrefix}pms to me - 重置为发给自己 -• ${mainPrefix}pms target - 查看当前目标 - -来源显示控制: -• ${mainPrefix}pms source on/off - 开启/关闭来源显示功能 -• ${mainPrefix}pms source - 查看当前来源显示状态 - -转发消息: -• ${mainPrefix}pms - 回复要转发的消息 -• ${mainPrefix}pms [链接1] [链接2] ... - 批量转发 -• ${mainPrefix}pms [链接] [临时目标] - 临时转发到指定对话 -• ${mainPrefix}pms [链接1]|[链接2] - 保存两个链接之间的所有消息(支持不连续编号,自动跳过不存在消息) - -💡 示例: -• ${mainPrefix}pms to @group - 设置默认目标 -• ${mainPrefix}pms to -123456780 - 设置chatid为目标 -• ${mainPrefix}pms - 回复消息进行转发 -• ${mainPrefix}pms https://t.me/c/123/1 https://t.me/c/123/2 - 批量转发 -• ${mainPrefix}pms https://t.me/c/123/1 @username - 转发到指定用户 -• ${mainPrefix}pms t.me/c/123/1|t.me/c/123/100 - 自动保存123群组/频道内1-100号消息 - -📊 支持类型: -• 文本、图片、视频、音频、语音 -• 文档、贴纸、GIF动画 -• 轮播相册、链接预览 -• 投票、地理位置`; - -class PrometheusPlugin extends Plugin { - cleanup(): void { - // 当前插件不持有需要在 reload 时额外释放的长期资源。 - } - - name = "prometheus"; - description = help_text; - - private tempDir = createDirectoryInTemp("prometheus"); - private db: any = null; - private lastEditText: Map = new Map(); - - constructor() { - super(); - this.initDB(); - } - - // 安全编辑(防 MESSAGE_EMPTY) - private async safeEditMessage( - msg: Api.Message, - text: string, - force: boolean = false - ): Promise { - const msgId = `${msg.chatId}_${msg.id}`; - const lastText = this.lastEditText.get(msgId); - - // 关键兜底:绝对不给空字符串 - const safeText = text?.trim() || ' '; // 用不可见空格占位 - if (!force && lastText === safeText) return; - - try { - await msg.edit({ text: safeText, parseMode: 'html' }); - this.lastEditText.set(msgId, safeText); - } catch (err: any) { - if (err.message?.includes('MESSAGE_NOT_MODIFIED')) { - this.lastEditText.set(msgId, safeText); - return; - } - throw err; - } - } - - private async initDB(): Promise { - try { - const dbPath = path.join(createDirectoryInAssets("prometheus"), "config.json"); - this.db = await JSONFilePreset(dbPath, { users: {} }); - } catch (error) { - console.error(`初始化数据库失败:`, error); - } - } - - private async getUserConfig(userId: string): Promise { - await this.initDB(); - if (!this.db.data.users[userId]) { - this.db.data.users[userId] = { - target: "me", - showSource: false // 默认关闭来源显示 - }; - await this.db.write(); - } - return this.db.data.users[userId]; - } - - private async setUserConfig(userId: string, config: Partial): Promise { - await this.initDB(); - if (!this.db.data.users[userId]) { - this.db.data.users[userId] = { - target: "me", - showSource: false - }; - } - Object.assign(this.db.data.users[userId], config); - await this.db.write(); - } - - // 生成消息跳转链接 - private generateMessageLink(chatId: string, messageId: number): string { - // 处理私有频道的chatId转换 - let linkChatId = chatId; - - // 如果chatId是数字字符串(可能为负数) - if (/^-?\d+$/.test(chatId)) { - // 如果以-100开头,需要去掉-100前缀 - if (chatId.startsWith('-100')) { - linkChatId = `-${chatId.substring(4)}`; - } else if (!chatId.startsWith('-') && parseInt(chatId) > 0) { - // 正数且不是频道格式,加上-100前缀 - linkChatId = `-100${chatId}`; - } - - // 最终格式:去掉-100前缀后的负号格式 - if (linkChatId.startsWith('-100')) { - linkChatId = `-${linkChatId.substring(4)}`; - } - - return `https://t.me/c/${linkChatId}/${messageId}`; - } - - // 如果是用户名格式,直接使用 - return `https://t.me/${chatId}/${messageId}`; - } - - // 发送来源消息(回复指定的消息) - private async sendSourceMessage( - targetPeer: any, - sourceChatId: string, - sourceMessageId: number, - forwardedMsg: Api.Message, - replyMsg?: Api.Message - ): Promise { - try { - const client = await getGlobalClient(); - const sourceLink = this.generateMessageLink(sourceChatId, sourceMessageId); - - const sourceText = `🔗 消息来源\n\n` + - `📝 查看原消息\n` + - `👤 来源对话: ${htmlEscape(sourceChatId)}\n` + - `#️⃣ 消息ID: ${sourceMessageId}`; - - // 回复转发的消息 - await client.sendMessage(targetPeer, { - message: sourceText, - parseMode: 'html', - replyTo: forwardedMsg.id - }); - - if (replyMsg) { - await this.safeEditMessage(replyMsg, `✅ 已转发并添加来源链接`, true); - } - } catch (error) { - console.error(`发送来源消息失败:`, error); - // 不中断主流程,只是来源消息发送失败 - } - } - - private parseMessageLink(link: string): { chatId: string; messageId: number } | null { - const cleanLink = link.split('?')[0]; - - const patterns = [ - /(?:https?:\/\/)?t\.me\/c\/(-?\d+)\/(\d+)/, - /(?:https?:\/\/)?t\.me\/([a-zA-Z0-9_]+)\/(\d+)/, - ]; - - for (const pattern of patterns) { - const match = cleanLink.match(pattern); - if (match) { - let chatId = match[1]; - const messageId = parseInt(match[2]); - - if (/^-?\d+$/.test(chatId) && !chatId.startsWith('-100') && chatId.startsWith('-')) { - chatId = `-100${chatId.substring(1)}`; - } else if (/^\d+$/.test(chatId) && parseInt(chatId) > 0) { - chatId = `-100${chatId}`; - } - - return { chatId, messageId }; - } - } - - return null; - } - - private async getMessage(chatId: string, messageId: number): Promise { - try { - const client = await getGlobalClient(); - const peer = await client.getInputEntity(chatId); - const messages = await client.getMessages(peer, { ids: [messageId] }); - return messages[0] || null; - } catch (error) { - console.error(`获取消息失败:`, error); - return null; - } - } - - private getFileExtension(media: Api.TypeMessageMedia): string { - try { - if (media instanceof Api.MessageMediaPhoto) { - return '.jpg'; - } else if (media instanceof Api.MessageMediaDocument) { - const document = media.document as Api.Document; - - if (document.mimeType) { - const mimeType = document.mimeType.toLowerCase(); - if (mimeType.includes('video/mp4')) return '.mp4'; - if (mimeType.includes('video/webm')) return '.webm'; - if (mimeType.includes('video/quicktime')) return '.mov'; - if (mimeType.includes('audio/mpeg')) return '.mp3'; - if (mimeType.includes('audio/ogg')) return '.ogg'; - if (mimeType.includes('image/jpeg') || mimeType.includes('image/jpg')) return '.jpg'; - if (mimeType.includes('image/png')) return '.png'; - if (mimeType.includes('image/gif')) return '.gif'; - if (mimeType.includes('image/webp')) return '.webp'; - } - - for (const attr of document.attributes) { - if (attr instanceof Api.DocumentAttributeFilename) { - const ext = path.extname(attr.fileName).toLowerCase(); - if (ext) return ext; - } - } - - for (const attr of document.attributes) { - if (attr instanceof Api.DocumentAttributeVideo) return '.mp4'; - if (attr instanceof Api.DocumentAttributeAudio) return attr.voice ? '.ogg' : '.mp3'; - if (attr instanceof Api.DocumentAttributeSticker) return '.webp'; - if (attr instanceof Api.DocumentAttributeAnimated) return '.gif'; - } - } - } catch (error) { - console.error(`获取文件扩展名失败:`, error); - } - - return '.bin'; - } - - private getMediaType(media: Api.TypeMessageMedia): string { - try { - if (media instanceof Api.MessageMediaPhoto) { - return 'photo'; - } else if (media instanceof Api.MessageMediaDocument) { - const document = media.document as Api.Document; - - for (const attr of document.attributes) { - if (attr instanceof Api.DocumentAttributeVideo) return 'video'; - if (attr instanceof Api.DocumentAttributeAudio) return attr.voice ? 'voice' : 'audio'; - if (attr instanceof Api.DocumentAttributeSticker) return 'sticker'; - if (attr instanceof Api.DocumentAttributeAnimated) return 'gif'; - } - - if (document.mimeType?.includes('video/')) return 'video'; - if (document.mimeType?.includes('audio/')) return 'audio'; - if (document.mimeType?.includes('image/')) return 'photo'; - } - } catch (error) { - console.error(`获取媒体类型失败:`, error); - } - - return 'document'; - } - - private async downloadMedia(message: Api.Message, index: number = 0, replyMsg?: Api.Message): Promise<{ - path: string; - type: string; - caption?: string; - fileName?: string; - } | null> { - try { - const client = await getGlobalClient(); - - if (!message.media) return null; - - const mediaType = this.getMediaType(message.media); - const extension = this.getFileExtension(message.media); - - const timestamp = Date.now(); - let fileName = `${mediaType}_${timestamp}_${index}`; - - if (message.media instanceof Api.MessageMediaDocument) { - const document = message.media.document as Api.Document; - for (const attr of document.attributes) { - if (attr instanceof Api.DocumentAttributeFilename) { - const baseName = path.parse(attr.fileName).name; - if (baseName) fileName = baseName; - break; - } - } - } - - const safeName = fileName.replace(/[^a-zA-Z0-9_\-\.]/g, '_'); - const finalFileName = `${safeName}${extension}`; - const filePath = path.join(this.tempDir, finalFileName); - - let finalFilePath = filePath; - let counter = 1; - while (existsSync(finalFilePath)) { - const baseName = path.parse(safeName).name; - finalFilePath = path.join(this.tempDir, `${baseName}_${counter}${extension}`); - counter++; - } - - if (replyMsg) { - await this.safeEditMessage(replyMsg, `⏬ 下载媒体文件 (${index + 1})...`); - } - - const buffer = await client.downloadMedia(message.media, {}); - if (buffer && buffer.length > 0) { - await fs.writeFile(finalFilePath, buffer); - } else { - return null; - } - - if (!existsSync(finalFilePath)) { - return null; - } - - const stats = statSync(finalFilePath); - if (stats.size === 0) { - return null; - } - - return { - path: finalFilePath, - type: mediaType, - caption: message.text || undefined, - fileName: path.basename(finalFilePath), - }; - - } catch (error: any) { - console.error(`下载媒体失败:`, error); - return null; - } - } - - private async cleanupTempFile(filePath: string | null): Promise { - if (filePath && existsSync(filePath)) { - try { - await fs.unlink(filePath); - } catch (error) { - console.error(`清理临时文件失败:`, error); - } - } - } - - private async sendSingleMedia( - client: any, - targetPeer: any, - mediaInfo: { - path: string; - type: string; - caption?: string; - fileName?: string; - }, - replyMsg?: Api.Message - ): Promise { - const { path: filePath, type, caption, fileName } = mediaInfo; - - if (!existsSync(filePath)) { - throw new Error(`文件不存在: ${filePath}`); - } - - const sendOptions: any = { - file: filePath, - forceDocument: false - }; - - if (caption && type !== 'voice' && type !== 'sticker') { - sendOptions.caption = caption; - sendOptions.parseMode = caption.includes('<') ? 'html' : undefined; - } - - if (replyMsg) { - await this.safeEditMessage(replyMsg, `📤 上传 ${type}...`); - } - - return await client.sendFile(targetPeer, sendOptions); - } - - private async processMessage( - sourceMsg: Api.Message, - targetPeer: any, - replyMsg: Api.Message, - sourceChatId: string, - sourceMessageId: number, - showSource: boolean, - progress: string = "" - ): Promise<{ success: boolean; forwardedMsg?: Api.Message }> { - const client = await getGlobalClient(); - let tempFileInfo: any = null; - let forwardedMessage: Api.Message | undefined; - - try { - await this.safeEditMessage(replyMsg, `${progress}🔄 尝试直接转发...`, true); - - try { - // 直接转发,获取转发的消息 - const result = await client.forwardMessages(targetPeer, { - messages: [sourceMsg.id], - fromPeer: sourceMsg.peerId - }); - forwardedMessage = result[0]; - - await this.safeEditMessage(replyMsg, `${progress}✅ 转发成功`, true); - - // 如果开启了来源显示,发送来源消息 - if (showSource && forwardedMessage) { - await this.sendSourceMessage(targetPeer, sourceChatId, sourceMessageId, forwardedMessage, replyMsg); - } else { - return { success: true, forwardedMsg: forwardedMessage }; - } - - return { success: true, forwardedMsg: forwardedMessage }; - } catch (forwardError: any) { - const errorMsg = forwardError.message || ''; - const isRestricted = errorMsg.includes('SAVE') || - errorMsg.includes('FORWARD') || - errorMsg.includes('CHAT_FORWARDS_RESTRICTED'); - - if (!isRestricted) throw forwardError; - - if (!sourceMsg.media) { - const text = sourceMsg.text || ''; - if (text) { - // 发送文本消息,获取发送的消息 - forwardedMessage = await client.sendMessage(targetPeer, { - message: text, - parseMode: sourceMsg.text?.includes('<') ? 'html' : undefined - }); - - await this.safeEditMessage(replyMsg, `${progress}✅ 文本内容已发送`, true); - - // 如果开启了来源显示,发送来源消息 - if (showSource && forwardedMessage) { - await this.sendSourceMessage(targetPeer, sourceChatId, sourceMessageId, forwardedMessage, replyMsg); - } else { - return { success: true, forwardedMsg: forwardedMessage }; - } - - return { success: true, forwardedMsg: forwardedMessage }; - } else { - await this.safeEditMessage(replyMsg, `${progress}❌ 消息无内容可转发`, true); - return { success: false }; - } - } - - tempFileInfo = await this.downloadMedia(sourceMsg, 0, replyMsg); - if (!tempFileInfo) { - await this.safeEditMessage(replyMsg, `${progress}❌ 下载媒体失败`, true); - return { success: false }; - } - - // 发送媒体消息,获取发送的消息 - forwardedMessage = await this.sendSingleMedia(client, targetPeer, tempFileInfo, replyMsg); - await this.safeEditMessage(replyMsg, `${progress}✅ 内容已重新上传发送`, true); - - // 如果开启了来源显示,发送来源消息 - if (showSource && forwardedMessage) { - await this.sendSourceMessage(targetPeer, sourceChatId, sourceMessageId, forwardedMessage, replyMsg); - } else { - return { success: true, forwardedMsg: forwardedMessage }; - } - - return { success: true, forwardedMsg: forwardedMessage }; - } - } catch (error: any) { - console.error(`处理消息失败:`, error); - await this.safeEditMessage(replyMsg, `${progress}❌ 处理失败: ${htmlEscape(error.message || "未知错误")}`, true); - return { success: false }; - } finally { - if (tempFileInfo?.path) { - await this.cleanupTempFile(tempFileInfo.path); - } - } - } - - // 处理消息范围 - private async processMessageRange( - chatId: string, - startId: number, - endId: number, - targetPeer: any, - replyMsg: Api.Message, - showSource: boolean - ): Promise<{ total: number; success: number }> { - const client = await getGlobalClient(); - let successCount = 0; - let totalProcessed = 0; - - // 确保startId <= endId - const actualStart = Math.min(startId, endId); - const actualEnd = Math.max(startId, endId); - const totalMessages = actualEnd - actualStart + 1; - - await this.safeEditMessage(replyMsg, `🔄 开始处理消息范围 ${actualStart}-${actualEnd} (共${totalMessages}条)...`, true); - - for (let msgId = actualStart; msgId <= actualEnd; msgId++) { - totalProcessed++; - const progress = `[${totalProcessed}/${totalMessages}] `; - - try { - await this.safeEditMessage(replyMsg, `${progress}🔍 获取消息 ${msgId}...`, true); - const sourceMsg = await this.getMessage(chatId, msgId); - - if (!sourceMsg) { - await this.safeEditMessage(replyMsg, `${progress}⏭️ 消息 ${msgId} 不存在,跳过`, true); - continue; - } - - await this.safeEditMessage(replyMsg, `${progress}🔄 处理消息 ${msgId}...`, true); - const result = await this.processMessage( - sourceMsg, - targetPeer, - replyMsg, - chatId, - msgId, - showSource, - progress - ); - - if (result.success) { - successCount++; - await this.safeEditMessage(replyMsg, `${progress}✅ 消息 ${msgId} 处理完成`, true); - } else { - await this.safeEditMessage(replyMsg, `${progress}❌ 消息 ${msgId} 处理失败`, true); - } - } catch (error: any) { - await this.safeEditMessage(replyMsg, `${progress}❌ 消息 ${msgId} 处理出错: ${htmlEscape(error.message || "未知错误")}`, true); - } - - // 延迟以避免触发限制 - if (msgId < actualEnd) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - return { total: totalProcessed, success: successCount }; - } - - // 主要处理函数 - private async handleCommand(msg: Api.Message): Promise { - try { - const client = await getGlobalClient(); - if (!client) { - await this.safeEditMessage(msg, "❌ 客户端未初始化", true); - return; - } - - const userId = msg.senderId?.toString() || "unknown"; - const text = msg.text || ""; - const parts = text.trim().split(/\s+/); - - // 检查是否有回复消息 - const replyMsg = await msg.getReplyMessage(); - - // 处理source子命令 - if (parts.length >= 2 && parts[1].toLowerCase() === "source") { - const config = await this.getUserConfig(userId); - - if (parts.length === 2) { - // 查看当前状态 - const status = config.showSource ? "开启 ✅" : "关闭 ❌"; - await this.safeEditMessage(msg, `📊 来源显示功能: ${status}\n\n`, true); - return; - } - - const action = parts[2].toLowerCase(); - if (action === "on") { - await this.setUserConfig(userId, { showSource: true }); - await this.safeEditMessage(msg, "✅ 已开启来源显示功能\n\n转发消息后,将回复一条包含原消息链接的来源消息。", true); - } else if (action === "off") { - await this.setUserConfig(userId, { showSource: false }); - await this.safeEditMessage(msg, "❌ 已关闭来源显示功能\n\n转发消息后,将不再显示来源链接。", true); - } else { - await this.safeEditMessage(msg, "❌ 无效的参数\n\n使用: ${mainPrefix}pms source on/off", true); - } - return; - } - - // 处理子命令 - if (parts.length >= 2 && parts[1].toLowerCase() === "to") { - if (parts.length < 3) { - await this.safeEditMessage(msg, "❌ 请指定转发目标\n\n💡 示例:\n${mainPrefix}pms to @username\n${mainPrefix}pms to -123456780\n${mainPrefix}pms to me", true); - return; - } - - const target = parts.slice(2).join(" "); - await this.setUserConfig(userId, { target }); - await this.safeEditMessage(msg, `✅ 已设置默认转发目标为: ${htmlEscape(target)}`, true); - return; - } - - if (parts.length >= 2 && parts[1].toLowerCase() === "target") { - const config = await this.getUserConfig(userId); - await this.safeEditMessage(msg, `📌 当前默认转发目标: ${htmlEscape(config.target)}`, true); - return; - } - - // 处理帮助命令 - if (parts.length === 2 && (parts[1] === 'help' || parts[1] === 'h')) { - await this.safeEditMessage(msg, help_text, true); - return; - } - - // 获取用户配置 - const config = await this.getUserConfig(userId); - let target = config.target; - const showSource = config.showSource; - - // 解析链接和临时目标 - const links: string[] = []; - let tempTarget: string | null = null; - let rangeMode = false; - let rangeInfo: { chatId: string; startId: number; endId: number } | null = null; - - for (let i = 1; i < parts.length; i++) { - const part = parts[i]; - - // 检查是否是范围模式(包含|) - if (part.includes('|') && (part.startsWith('http') || part.startsWith('t.me'))) { - const rangeParts = part.split('|'); - if (rangeParts.length === 2) { - const link1 = this.parseMessageLink(rangeParts[0]); - const link2 = this.parseMessageLink(rangeParts[1]); - - if (link1 && link2 && link1.chatId === link2.chatId) { - rangeMode = true; - rangeInfo = { - chatId: link1.chatId, - startId: link1.messageId, - endId: link2.messageId - }; - break; - } - } - } else if (part.startsWith('http') || part.startsWith('t.me')) { - links.push(part); - } else if (i === parts.length - 1 && links.length > 0) { - tempTarget = part; - } - } - - if (tempTarget) target = tempTarget; - - // 如果既没有链接也没有回复消息,显示帮助 - if (links.length === 0 && !replyMsg && !rangeMode) { - await this.safeEditMessage(msg, help_text, true); - return; - } - - // 获取目标对话实体 - let targetPeer: any; - try { - targetPeer = await client.getInputEntity(target); - } catch (error) { - await this.safeEditMessage(msg, `❌ 无法访问目标对话: ${htmlEscape(target)}`, true); - return; - } - - // 范围模式处理 - if (rangeMode && rangeInfo) { - await this.safeEditMessage(msg, `🔍 进入范围模式: ${rangeInfo.startId}-${rangeInfo.endId}`, true); - const result = await this.processMessageRange( - rangeInfo.chatId, - rangeInfo.startId, - rangeInfo.endId, - targetPeer, - msg, - showSource - ); - - await this.safeEditMessage(msg, `✅ 范围处理完成\n成功: ${result.success}/${result.total} 条消息`, true); - return; - } - - // 处理消息 - const messagesToProcess: Array<{ - chatId: string; - messageId: number; - groupedId?: string; - isMediaGroup?: boolean; - }> = []; - - // 链接模式 - if (links.length > 0) { - for (const link of links) { - const linkInfo = this.parseMessageLink(link); - if (!linkInfo) { - await this.safeEditMessage(msg, `❌ 无效的消息链接: ${htmlEscape(link)}`, true); - return; - } - - const sourceMsg = await this.getMessage(linkInfo.chatId, linkInfo.messageId); - if (sourceMsg) { - if (sourceMsg.groupedId) { - const existingGroup = messagesToProcess.find(m => - m.groupedId === sourceMsg.groupedId?.toString() - ); - if (!existingGroup) { - messagesToProcess.push({ - chatId: linkInfo.chatId, - messageId: linkInfo.messageId, - groupedId: sourceMsg.groupedId?.toString(), - isMediaGroup: true - }); - } - } else { - messagesToProcess.push({ - chatId: linkInfo.chatId, - messageId: linkInfo.messageId - }); - } - } else { - await this.safeEditMessage(msg, `❌ 无法获取消息: ${link}`, true); - return; - } - } - } - // 回复模式 - else if (replyMsg) { - messagesToProcess.push({ - chatId: replyMsg.peerId?.toString() || "", - messageId: replyMsg.id, - groupedId: replyMsg.groupedId?.toString() - }); - } - - if (messagesToProcess.length === 0) { - await this.safeEditMessage(msg, "❌ 未找到要转发的消息", true); - return; - } - - const total = messagesToProcess.length; - const mediaGroups = new Map(); - let successCount = 0; - - await this.safeEditMessage(msg, `🔄 开始处理 ${total} 个消息/媒体组...`, true); - - for (let i = 0; i < messagesToProcess.length; i++) { - const messageInfo = messagesToProcess[i]; - const progress = total > 1 ? `[${i + 1}/${total}] ` : ""; - - if (messageInfo.isMediaGroup && messageInfo.groupedId) { - if (mediaGroups.has(messageInfo.groupedId)) continue; - mediaGroups.set(messageInfo.groupedId, true); - - // 简化处理:对于媒体组,逐个消息处理 - const client = await getGlobalClient(); - const peer = await client.getInputEntity(messageInfo.chatId); - const searchIds: number[] = []; - - for (let j = 0; j <= 60; j++) { - const id = messageInfo.messageId - 30 + j; - if (id > 0) searchIds.push(id); - } - - const messages = await client.getMessages(peer, { ids: searchIds }); - const groupMessages = messages.filter((msg): msg is Api.Message => - msg && (msg as Api.Message).groupedId?.toString() === messageInfo.groupedId - ); - - groupMessages.sort((a, b) => a.id - b.id); - - for (let j = 0; j < groupMessages.length; j++) { - const groupMsg = groupMessages[j]; - const groupProgress = `[${i + 1}/${total}] [${j + 1}/${groupMessages.length}] `; - const result = await this.processMessage( - groupMsg, - targetPeer, - msg, - messageInfo.chatId, - groupMsg.id, - showSource, - groupProgress - ); - - if (result.success) successCount++; - - if (j < groupMessages.length - 1) { - await new Promise(resolve => setTimeout(resolve, 300)); - } - } - } else { - const sourceMsg = await this.getMessage(messageInfo.chatId, messageInfo.messageId); - if (sourceMsg) { - const result = await this.processMessage( - sourceMsg, - targetPeer, - msg, - messageInfo.chatId, - messageInfo.messageId, - showSource, - progress - ); - if (result.success) successCount++; - } - } - - if (i < messagesToProcess.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - if (total > 1) { - await this.safeEditMessage(msg, `✅ 批量处理完成\n成功处理 ${successCount}/${total} 个消息/媒体组`, true); - } - - } catch (error: any) { - console.error(`prometheus命令执行失败:`, error); - await this.safeEditMessage(msg, `❌ 执行失败: ${htmlEscape(error.message || "未知错误")}`, true); - } - } - - cmdHandlers = { - prometheus: async (msg: Api.Message): Promise => { - await this.handleCommand(msg); - }, - pms: async (msg: Api.Message): Promise => { - await this.handleCommand(msg); - } - }; -} - -export default new PrometheusPlugin(); diff --git a/save/save.ts b/save/save.ts new file mode 100644 index 00000000..2f884c50 --- /dev/null +++ b/save/save.ts @@ -0,0 +1,1466 @@ +import { Plugin } from "@utils/pluginBase"; +import { getPrefixes } from "@utils/pluginManager"; +import { Api } from "teleproto"; +import { getGlobalClient } from "@utils/globalClient"; +import { createDirectoryInAssets, createDirectoryInTemp } from "@utils/pathHelpers"; +import { JSONFilePreset } from "lowdb/node"; +import * as path from "path"; +import * as fs from "fs/promises"; +import { statSync, existsSync } from "fs"; +import { CustomFile } from 'teleproto/client/uploads'; + +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; + + +const htmlEscape = (text: string): string => + text.replace(/[&<>"']/g, m => ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + }[m] || m)); + +interface UserConfig { + target: string; + showSource: boolean; // 新增:来源显示配置 +} + +interface PrometheusDB { + users: Record; +} + +interface LocalSavedFile { + filePath: string; + metadataPath: string; + relativeFilePath: string; + relativeMetadataPath: string; + sourceChatId: string; + sourceChatTitle: string; + sourceMessageId: number; + sourceLink: string; + mediaType: string; + groupedId?: string; +} + +interface ProcessMessageResult { + success: boolean; + skipped?: boolean; + forwardedMsg?: Api.Message; + savedFile?: LocalSavedFile; + source?: { chatId: string; messageId: number }; +} + +const help_text = `🔥Prometheus -突破Telegram保存限制 + +
"To defy Power, which seems omnipotent." +—Percy Bysshe Shelley, Prometheus Unbound
+ +📝 功能: +• 突破"限制保存内容",转发任何消息 +• 支持批量处理多个消息链接 +• 支持范围保存功能(自动保存指定范围内的所有消息) +• 支持来源显示功能 +• 支持将媒体文件直接保存到本地 save/ 目录 +• 使用 ${mainPrefix}save 快速保存消息 + +🔧 使用方法: + +设置默认目标: +• ${mainPrefix}save to [目标] - 设置默认转发目标(支持用户名、chatid如-123456780、'me'、'local') +• ${mainPrefix}save to me - 重置为发给自己 +• ${mainPrefix}save to local - 将媒体保存到本地 save/ 文件夹 +• ${mainPrefix}save target - 查看当前目标 + +来源显示控制: +• ${mainPrefix}save source on/off - 开启/关闭来源显示功能 +• ${mainPrefix}save source - 查看当前来源显示状态 + +转发消息: +• ${mainPrefix}save - 回复要转发的消息 +• ${mainPrefix}save [链接1] [链接2] ... - 批量转发 +• ${mainPrefix}save [链接] [临时目标] - 临时转发到指定对话 +• ${mainPrefix}save [链接] local - 临时保存该媒体到本地 +• ${mainPrefix}save [链接1]|[链接2] - 保存两个链接之间的所有消息(支持不连续编号,自动跳过不存在消息) + +💡 示例: +• ${mainPrefix}save to @group - 设置默认目标 +• ${mainPrefix}save to -123456780 - 设置chatid为目标 +• ${mainPrefix}save to local - 设置默认保存到本地 +• ${mainPrefix}save - 回复消息进行转发 +• ${mainPrefix}save https://t.me/c/123/1 https://t.me/c/123/2 - 批量转发 +• ${mainPrefix}save https://t.me/c/123/1 @username - 转发到指定用户 +• ${mainPrefix}save https://t.me/c/123/1 local - 临时保存该媒体到本地 +• ${mainPrefix}save t.me/c/123/1|t.me/c/123/100 - 自动保存123群组/频道内1-100号消息 + +📊 支持类型: +• 文本、图片、视频、音频、语音 +• 文档、贴纸、GIF动画 +• 轮播相册、链接预览 +• 投票、地理位置 + +💾 本地模式说明: +• 仅保存媒体文件,纯文本消息会自动跳过 +• 文件保存到 save/ 下的来源对话子目录 +• 每个媒体文件旁会生成同名 .json 来源元数据`; + +class PrometheusPlugin extends Plugin { + cleanup(): void { + this.lastEditText.clear(); + this.chatDisplayNameCache.clear(); + this.db = null; + for (const filePath of this.activeTempFiles) { + void fs.unlink(filePath).catch(() => {}); + } + this.activeTempFiles.clear(); + void this.cleanupTempDirectory(); + } + + name = "save"; + description = help_text; + + private tempDir = createDirectoryInTemp("prometheus"); + private db: any = null; + private lastEditText: Map = new Map(); + private chatDisplayNameCache: Map = new Map(); + private activeTempFiles: Set = new Set(); + + private isLocalTarget(target: string): boolean { + return target.trim().toLowerCase() === "local"; + } + + private sanitizePathSegment(value: string, fallback: string): string { + const sanitized = value + .trim() + .replace(/[^a-zA-Z0-9_\-.\u4e00-\u9fff]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80); + + return sanitized || fallback; + } + + private getLocalSaveRoot(): string { + return path.join(process.cwd(), "save"); + } + + private buildUniquePath(dirPath: string, fileName: string): string { + const parsed = path.parse(fileName); + const baseName = parsed.name || "file"; + const extension = parsed.ext || ""; + let candidate = path.join(dirPath, `${baseName}${extension}`); + let counter = 1; + + while (existsSync(candidate)) { + candidate = path.join(dirPath, `${baseName}_${counter}${extension}`); + counter++; + } + + return candidate; + } + + private async moveFile(sourcePath: string, destinationPath: string): Promise { + try { + await fs.rename(sourcePath, destinationPath); + } catch { + await fs.copyFile(sourcePath, destinationPath); + await fs.unlink(sourcePath); + } + } + + private async saveLocalMetadata(filePath: string, metadata: Record): Promise { + const parsed = path.parse(filePath); + const metadataPath = this.buildUniquePath(parsed.dir, `${parsed.name}.json`); + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + return metadataPath; + } + + private async ensureLocalChatDirectory(chatId: string): Promise<{ rootDir: string; chatDir: string; displayName: string }> { + const rootDir = this.getLocalSaveRoot(); + await fs.mkdir(rootDir, { recursive: true }); + + const displayName = await this.getChatDisplayName(chatId); + const dirName = this.sanitizePathSegment(chatId, "chat"); + const chatDir = path.join(rootDir, dirName); + await fs.mkdir(chatDir, { recursive: true }); + + return { rootDir, chatDir, displayName }; + } + + private getLocalChatRelativeDirectory(chatId: string): string { + const chatDir = path.join(this.getLocalSaveRoot(), this.sanitizePathSegment(chatId, "chat")); + return path.relative(process.cwd(), chatDir) || chatDir; + } + + private formatLocalDirectorySummary(savedFiles: LocalSavedFile[]): string { + const directories = Array.from( + new Set(savedFiles.map((file) => this.getLocalChatRelativeDirectory(file.sourceChatId))) + ).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); + + if (directories.length === 0) { + return `目录: ${htmlEscape(path.relative(process.cwd(), this.getLocalSaveRoot()) || this.getLocalSaveRoot())}`; + } + + if (directories.length === 1) { + return `目录: ${htmlEscape(directories[0])}`; + } + + const body = directories.map((dir) => `${htmlEscape(dir)}`).join('\n'); + return `目录摘要:\n
${body}
`; + } + + private formatSourceLine(chatLabel: string, chatId: string, messageId: number): string { + const sourceLink = this.generateMessageLink(chatId, messageId); + return `• ${htmlEscape(chatLabel)} / ${messageId}`; + } + + private compactMessageIds(messageIds: number[]): Array<{ start: number; end: number }> { + if (messageIds.length === 0) return []; + + const sortedIds = Array.from(new Set(messageIds)).sort((a, b) => a - b); + const ranges: Array<{ start: number; end: number }> = []; + + let start = sortedIds[0]; + let end = sortedIds[0]; + + for (let i = 1; i < sortedIds.length; i++) { + const current = sortedIds[i]; + if (current === end + 1) { + end = current; + continue; + } + + ranges.push({ start, end }); + start = current; + end = current; + } + + ranges.push({ start, end }); + return ranges; + } + + private formatSourceRange(chatId: string, start: number, end: number): string { + const startLink = this.generateMessageLink(chatId, start); + if (start === end) { + return `${start}`; + } + + const endLink = this.generateMessageLink(chatId, end); + return `${start}-${end}`; + } + + private async getChatDisplayName(chatId: string): Promise { + const cachedName = this.chatDisplayNameCache.get(chatId); + if (cachedName) { + return cachedName; + } + + try { + const client = await getGlobalClient(); + const entity = await client.getEntity(chatId); + + const title = + (entity as any)?.title || + [ + (entity as any)?.firstName, + (entity as any)?.lastName, + ].filter(Boolean).join(' ').trim() || + (entity as any)?.username; + + const resolvedName = title ? String(title) : chatId; + this.chatDisplayNameCache.set(chatId, resolvedName); + return resolvedName; + } catch { + this.chatDisplayNameCache.set(chatId, chatId); + return chatId; + } + } + + private async sendSingleSourceMessage( + targetPeer: any, + sourceChatId: string, + sourceMessageId: number, + forwardedMsg: Api.Message, + replyMsg?: Api.Message + ): Promise { + try { + const client = await getGlobalClient(); + const sourceLink = this.generateMessageLink(sourceChatId, sourceMessageId); + const displayName = await this.getChatDisplayName(sourceChatId); + const sourceText = `🔗 消息来源\n\n` + + `📝 查看原消息\n` + + `👤 来源对话: ${htmlEscape(displayName)}\n` + + `#️⃣ 消息ID: ${sourceMessageId}`; + + await client.sendMessage(targetPeer, { + message: sourceText, + parseMode: 'html', + replyTo: forwardedMsg.id + }); + + if (replyMsg) { + await this.safeEditMessage(replyMsg, `✅ 已转发并添加来源链接`, true); + } + } catch (error) { + console.error(`发送来源消息失败:`, error); + } + } + + private async sendBatchSourceSummary( + targetPeer: any, + forwardedMsg: Api.Message, + sources: Array<{ chatId: string; messageId: number }> + ): Promise { + if (sources.length === 0) return; + + try { + const client = await getGlobalClient(); + const grouped = new Map(); + + for (const source of sources) { + const current = grouped.get(source.chatId) || []; + current.push(source.messageId); + grouped.set(source.chatId, current); + } + + const sections = await Promise.all(Array.from(grouped.entries()).map(async ([chatId, messageIds]) => { + const uniqueIds = Array.from(new Set(messageIds)).sort((a, b) => a - b); + const ranges = this.compactMessageIds(uniqueIds).sort((a, b) => a.start - b.start); + const rangeText = ranges + .map((range) => this.formatSourceRange(chatId, range.start, range.end)) + .join(', '); + const displayName = await this.getChatDisplayName(chatId); + return { + chatId, + displayName, + text: `👤 ${htmlEscape(displayName)}(${uniqueIds.length} 条):${rangeText}`, + }; + })); + + sections.sort((a, b) => { + const titleCompare = a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'); + if (titleCompare !== 0) return titleCompare; + return a.chatId.localeCompare(b.chatId, 'zh-Hans-CN'); + }); + + const summaryBody = sections.map((section) => section.text).join('\n'); + const wrappedBody = summaryBody.length > 350 || sections.length > 6 + ? `
${summaryBody}
` + : summaryBody; + const sourceText = `🔗 批量保存来源\n\n${wrappedBody}`; + + await client.sendMessage(targetPeer, { + message: sourceText, + parseMode: 'html', + replyTo: forwardedMsg.id + }); + } catch (error) { + console.error(`发送批量来源消息失败:`, error); + } + } + + private async sendRangeSourceSummary( + targetPeer: any, + forwardedMsg: Api.Message, + startSource: { chatId: string; messageId: number } | null, + endSource: { chatId: string; messageId: number } | null + ): Promise { + if (!startSource && !endSource) return; + + try { + const client = await getGlobalClient(); + const blocks: string[] = []; + + if (startSource) { + const startTitle = await this.getChatDisplayName(startSource.chatId); + blocks.push(`▶️ 起始消息\n${this.formatSourceLine(startTitle, startSource.chatId, startSource.messageId)}`); + } + + if (endSource) { + const endTitle = await this.getChatDisplayName(endSource.chatId); + blocks.push(`⏹ 结尾消息\n${this.formatSourceLine(endTitle, endSource.chatId, endSource.messageId)}`); + } + + await client.sendMessage(targetPeer, { + message: `🔗 范围保存来源\n\n${blocks.join('\n\n')}`, + parseMode: 'html', + replyTo: forwardedMsg.id + }); + } catch (error) { + console.error(`发送范围来源消息失败:`, error); + } + } + + constructor() { + super(); + this.initDB(); + } + + // 安全编辑(防 MESSAGE_EMPTY) + private async safeEditMessage( + msg: Api.Message, + text: string, + force: boolean = false + ): Promise { + const msgId = `${msg.chatId}_${msg.id}`; + const lastText = this.lastEditText.get(msgId); + + // 关键兜底:绝对不给空字符串 + const safeText = text?.trim() || ' '; // 用不可见空格占位 + if (!force && lastText === safeText) return; + + try { + await msg.edit({ text: safeText, parseMode: 'html' }); + this.lastEditText.set(msgId, safeText); + } catch (err: any) { + if (err.message?.includes('MESSAGE_NOT_MODIFIED')) { + this.lastEditText.set(msgId, safeText); + return; + } + throw err; + } + } + + private async initDB(): Promise { + try { + const dbPath = path.join(createDirectoryInAssets("prometheus"), "config.json"); + this.db = await JSONFilePreset(dbPath, { users: {} }); + } catch (error) { + console.error(`初始化数据库失败:`, error); + } + } + + private async getUserConfig(userId: string): Promise { + await this.initDB(); + if (!this.db.data.users[userId]) { + this.db.data.users[userId] = { + target: "me", + showSource: false // 默认关闭来源显示 + }; + await this.db.write(); + } + return this.db.data.users[userId]; + } + + private async setUserConfig(userId: string, config: Partial): Promise { + await this.initDB(); + if (!this.db.data.users[userId]) { + this.db.data.users[userId] = { + target: "me", + showSource: false + }; + } + Object.assign(this.db.data.users[userId], config); + await this.db.write(); + } + + // 生成消息跳转链接 + private generateMessageLink(chatId: string, messageId: number): string { + // 处理私有频道的chatId转换 + let linkChatId = chatId; + + // 如果chatId是数字字符串(可能为负数) + if (/^-?\d+$/.test(chatId)) { + // 如果以-100开头,需要去掉-100前缀 + if (chatId.startsWith('-100')) { + linkChatId = `-${chatId.substring(4)}`; + } else if (!chatId.startsWith('-') && parseInt(chatId) > 0) { + // 正数且不是频道格式,加上-100前缀 + linkChatId = `-100${chatId}`; + } + + // 最终格式:去掉-100前缀后的负号格式 + if (linkChatId.startsWith('-100')) { + linkChatId = `-${linkChatId.substring(4)}`; + } + + return `https://t.me/c/${linkChatId}/${messageId}`; + } + + // 如果是用户名格式,直接使用 + return `https://t.me/${chatId}/${messageId}`; + } + + // 发送来源消息(回复指定的消息) + private parseMessageLink(link: string): { chatId: string; messageId: number } | null { + const cleanLink = link.split('?')[0]; + + const patterns = [ + /(?:https?:\/\/)?t\.me\/c\/(-?\d+)\/(\d+)/, + /(?:https?:\/\/)?t\.me\/([a-zA-Z0-9_]+)\/(\d+)/, + ]; + + for (const pattern of patterns) { + const match = cleanLink.match(pattern); + if (match) { + let chatId = match[1]; + const messageId = parseInt(match[2]); + + if (/^-?\d+$/.test(chatId) && !chatId.startsWith('-100') && chatId.startsWith('-')) { + chatId = `-100${chatId.substring(1)}`; + } else if (/^\d+$/.test(chatId) && parseInt(chatId) > 0) { + chatId = `-100${chatId}`; + } + + return { chatId, messageId }; + } + } + + return null; + } + + private async getMessage(chatId: string, messageId: number): Promise { + try { + const client = await getGlobalClient(); + const peer = await client.getInputEntity(chatId); + const messages = await client.getMessages(peer, { ids: [messageId] }); + return messages[0] || null; + } catch (error) { + console.error(`获取消息失败:`, error); + return null; + } + } + + private getFileExtension(media: Api.TypeMessageMedia): string { + try { + if (media instanceof Api.MessageMediaPhoto) { + return '.jpg'; + } else if (media instanceof Api.MessageMediaDocument) { + const document = media.document as Api.Document; + + if (document.mimeType) { + const mimeType = document.mimeType.toLowerCase(); + if (mimeType.includes('video/mp4')) return '.mp4'; + if (mimeType.includes('video/webm')) return '.webm'; + if (mimeType.includes('video/quicktime')) return '.mov'; + if (mimeType.includes('audio/mpeg')) return '.mp3'; + if (mimeType.includes('audio/ogg')) return '.ogg'; + if (mimeType.includes('image/jpeg') || mimeType.includes('image/jpg')) return '.jpg'; + if (mimeType.includes('image/png')) return '.png'; + if (mimeType.includes('image/gif')) return '.gif'; + if (mimeType.includes('image/webp')) return '.webp'; + } + + for (const attr of document.attributes) { + if (attr instanceof Api.DocumentAttributeFilename) { + const ext = path.extname(attr.fileName).toLowerCase(); + if (ext) return ext; + } + } + + for (const attr of document.attributes) { + if (attr instanceof Api.DocumentAttributeVideo) return '.mp4'; + if (attr instanceof Api.DocumentAttributeAudio) return attr.voice ? '.ogg' : '.mp3'; + if (attr instanceof Api.DocumentAttributeSticker) return '.webp'; + if (attr instanceof Api.DocumentAttributeAnimated) return '.gif'; + } + } + } catch (error) { + console.error(`获取文件扩展名失败:`, error); + } + + return '.bin'; + } + + private getMediaType(media: Api.TypeMessageMedia): string { + try { + if (media instanceof Api.MessageMediaPhoto) { + return 'photo'; + } else if (media instanceof Api.MessageMediaDocument) { + const document = media.document as Api.Document; + + for (const attr of document.attributes) { + if (attr instanceof Api.DocumentAttributeVideo) return 'video'; + if (attr instanceof Api.DocumentAttributeAudio) return attr.voice ? 'voice' : 'audio'; + if (attr instanceof Api.DocumentAttributeSticker) return 'sticker'; + if (attr instanceof Api.DocumentAttributeAnimated) return 'gif'; + } + + if (document.mimeType?.includes('video/')) return 'video'; + if (document.mimeType?.includes('audio/')) return 'audio'; + if (document.mimeType?.includes('image/')) return 'photo'; + } + } catch (error) { + console.error(`获取媒体类型失败:`, error); + } + + return 'document'; + } + + private async downloadMedia(message: Api.Message, index: number = 0, replyMsg?: Api.Message): Promise<{ + path: string; + type: string; + caption?: string; + fileName?: string; + } | null> { + try { + const client = await getGlobalClient(); + + if (!message.media) return null; + + const mediaType = this.getMediaType(message.media); + const extension = this.getFileExtension(message.media); + + const timestamp = Date.now(); + let fileName = `${mediaType}_${timestamp}_${index}`; + + if (message.media instanceof Api.MessageMediaDocument) { + const document = message.media.document as Api.Document; + for (const attr of document.attributes) { + if (attr instanceof Api.DocumentAttributeFilename) { + const baseName = path.parse(attr.fileName).name; + if (baseName) fileName = baseName; + break; + } + } + } + + const safeName = fileName.replace(/[^a-zA-Z0-9_\-\.]/g, '_'); + const finalFileName = `${safeName}${extension}`; + const filePath = path.join(this.tempDir, finalFileName); + + let finalFilePath = filePath; + let counter = 1; + while (existsSync(finalFilePath)) { + const baseName = path.parse(safeName).name; + finalFilePath = path.join(this.tempDir, `${baseName}_${counter}${extension}`); + counter++; + } + + if (replyMsg) { + await this.safeEditMessage(replyMsg, `⏬ 下载媒体文件 (${index + 1})...`); + } + + const buffer = await client.downloadMedia(message.media, {}); + if (buffer && buffer.length > 0) { + await fs.writeFile(finalFilePath, buffer); + this.activeTempFiles.add(finalFilePath); + } else { + return null; + } + + if (!existsSync(finalFilePath)) { + return null; + } + + const stats = statSync(finalFilePath); + if (stats.size === 0) { + return null; + } + + return { + path: finalFilePath, + type: mediaType, + caption: message.text || undefined, + fileName: path.basename(finalFilePath), + }; + + } catch (error: any) { + console.error(`下载媒体失败:`, error); + return null; + } + } + + private async cleanupTempFile(filePath: string | null): Promise { + if (filePath && existsSync(filePath)) { + try { + await fs.unlink(filePath); + } catch (error) { + console.error(`清理临时文件失败:`, error); + } + } + if (filePath) { + this.activeTempFiles.delete(filePath); + } + } + + private async cleanupTempDirectory(): Promise { + try { + const entries = await fs.readdir(this.tempDir); + await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(this.tempDir, entry); + try { + await fs.unlink(filePath); + } catch {} + }) + ); + } catch {} + } + + private async saveMediaToLocal( + sourceMsg: Api.Message, + sourceChatId: string, + sourceMessageId: number, + replyMsg?: Api.Message, + index: number = 0, + options?: { groupDirName?: string } + ): Promise { + let tempFileInfo: { + path: string; + type: string; + caption?: string; + fileName?: string; + } | null = null; + + try { + if (!sourceMsg.media) { + return null; + } + + tempFileInfo = await this.downloadMedia(sourceMsg, index, replyMsg); + if (!tempFileInfo) { + return null; + } + + const { chatDir, displayName } = await this.ensureLocalChatDirectory(sourceChatId); + const finalDir = options?.groupDirName + ? path.join(chatDir, this.sanitizePathSegment(options.groupDirName, "group")) + : chatDir; + await fs.mkdir(finalDir, { recursive: true }); + const originalName = tempFileInfo.fileName || path.basename(tempFileInfo.path); + const parsedOriginal = path.parse(originalName); + const safeBaseName = this.sanitizePathSegment(parsedOriginal.name || tempFileInfo.type || "media", tempFileInfo.type || "media"); + const safeExtension = parsedOriginal.ext || path.extname(originalName) || ".bin"; + const finalFileName = `msg_${sourceMessageId}_${safeBaseName}${safeExtension}`; + const finalFilePath = this.buildUniquePath(finalDir, finalFileName); + + await this.moveFile(tempFileInfo.path, finalFilePath); + this.activeTempFiles.delete(tempFileInfo.path); + tempFileInfo = null; + + const metadataPath = await this.saveLocalMetadata(finalFilePath, { + savedAt: new Date().toISOString(), + source: { + chatId: sourceChatId, + chatTitle: displayName, + messageId: sourceMessageId, + link: this.generateMessageLink(sourceChatId, sourceMessageId), + }, + media: { + type: sourceMsg.media ? this.getMediaType(sourceMsg.media) : null, + fileName: path.basename(finalFilePath), + originalFileName: originalName, + fileSize: statSync(finalFilePath).size, + caption: sourceMsg.text || "", + }, + }); + + return { + filePath: finalFilePath, + metadataPath, + relativeFilePath: path.relative(process.cwd(), finalFilePath) || finalFilePath, + relativeMetadataPath: path.relative(process.cwd(), metadataPath) || metadataPath, + sourceChatId, + sourceChatTitle: displayName, + sourceMessageId, + sourceLink: this.generateMessageLink(sourceChatId, sourceMessageId), + mediaType: sourceMsg.media ? this.getMediaType(sourceMsg.media) : "unknown", + groupedId: sourceMsg.groupedId?.toString(), + }; + } catch (error) { + console.error(`保存媒体到本地失败:`, error); + if (tempFileInfo?.path) { + await this.cleanupTempFile(tempFileInfo.path); + } + return null; + } + } + + private async sendSingleMedia( + client: any, + targetPeer: any, + mediaInfo: { + path: string; + type: string; + caption?: string; + fileName?: string; + }, + replyMsg?: Api.Message + ): Promise { + const { path: filePath, type, caption, fileName } = mediaInfo; + + if (!existsSync(filePath)) { + throw new Error(`文件不存在: ${filePath}`); + } + + const sendOptions: any = { + file: filePath, + forceDocument: false + }; + + if (caption && type !== 'voice' && type !== 'sticker') { + sendOptions.caption = caption; + sendOptions.parseMode = caption.includes('<') ? 'html' : undefined; + } + + if (replyMsg) { + await this.safeEditMessage(replyMsg, `📤 上传 ${type}...`); + } + + return await client.sendFile(targetPeer, sendOptions); + } + + private async processMessage( + sourceMsg: Api.Message, + targetPeer: any, + replyMsg: Api.Message, + sourceChatId: string, + sourceMessageId: number, + progress: string = "" + ): Promise { + const client = await getGlobalClient(); + let tempFileInfo: any = null; + let forwardedMessage: Api.Message | undefined; + + try { + await this.safeEditMessage(replyMsg, `${progress}🔄 尝试直接转发...`, true); + + try { + // 直接转发,获取转发的消息 + const result = await client.forwardMessages(targetPeer, { + messages: [sourceMsg.id], + fromPeer: sourceMsg.peerId + }); + forwardedMessage = result[0]; + + await this.safeEditMessage(replyMsg, `${progress}✅ 转发成功`, true); + + // 如果开启了来源显示,发送来源消息 + return { + success: true, + forwardedMsg: forwardedMessage, + source: { chatId: sourceChatId, messageId: sourceMessageId } + }; + } catch (forwardError: any) { + const errorMsg = forwardError.message || ''; + const isRestricted = errorMsg.includes('SAVE') || + errorMsg.includes('FORWARD') || + errorMsg.includes('CHAT_FORWARDS_RESTRICTED'); + + if (!isRestricted) throw forwardError; + + if (!sourceMsg.media) { + const text = sourceMsg.text || ''; + if (text) { + // 发送文本消息,获取发送的消息 + forwardedMessage = await client.sendMessage(targetPeer, { + message: text, + parseMode: sourceMsg.text?.includes('<') ? 'html' : undefined + }); + + await this.safeEditMessage(replyMsg, `${progress}✅ 文本内容已发送`, true); + + // 如果开启了来源显示,发送来源消息 + return { + success: true, + forwardedMsg: forwardedMessage, + source: { chatId: sourceChatId, messageId: sourceMessageId } + }; + } else { + await this.safeEditMessage(replyMsg, `${progress}❌ 消息无内容可转发`, true); + return { success: false }; + } + } + + tempFileInfo = await this.downloadMedia(sourceMsg, 0, replyMsg); + if (!tempFileInfo) { + await this.safeEditMessage(replyMsg, `${progress}❌ 下载媒体失败`, true); + return { success: false }; + } + + // 发送媒体消息,获取发送的消息 + forwardedMessage = await this.sendSingleMedia(client, targetPeer, tempFileInfo, replyMsg); + await this.safeEditMessage(replyMsg, `${progress}✅ 内容已重新上传发送`, true); + + // 如果开启了来源显示,发送来源消息 + return { + success: true, + forwardedMsg: forwardedMessage, + source: { chatId: sourceChatId, messageId: sourceMessageId } + }; + } + } catch (error: any) { + console.error(`处理消息失败:`, error); + await this.safeEditMessage(replyMsg, `${progress}❌ 处理失败: ${htmlEscape(error.message || "未知错误")}`, true); + return { success: false }; + } finally { + if (tempFileInfo?.path) { + await this.cleanupTempFile(tempFileInfo.path); + } + } + } + + private async processMessageToLocal( + sourceMsg: Api.Message, + replyMsg: Api.Message, + sourceChatId: string, + sourceMessageId: number, + progress: string = "", + options?: { groupDirName?: string; quietSuccess?: boolean } + ): Promise { + try { + if (!sourceMsg.media) { + await this.safeEditMessage(replyMsg, `${progress}⏭️ 消息不包含媒体文件,已跳过`, true); + return { success: false, skipped: true }; + } + + await this.safeEditMessage(replyMsg, `${progress}💾 正在保存媒体到本地...`, true); + const savedFile = await this.saveMediaToLocal(sourceMsg, sourceChatId, sourceMessageId, replyMsg, 0, options); + + if (!savedFile) { + await this.safeEditMessage(replyMsg, `${progress}❌ 保存到本地失败`, true); + return { success: false }; + } + + if (!options?.quietSuccess) { + await this.safeEditMessage(replyMsg, `${progress}✅ 已保存到本地`, true); + } + + return { + success: true, + savedFile, + source: { chatId: sourceChatId, messageId: sourceMessageId }, + }; + } catch (error: any) { + console.error(`处理本地保存失败:`, error); + await this.safeEditMessage(replyMsg, `${progress}❌ 本地保存失败: ${htmlEscape(error.message || "未知错误")}`, true); + return { success: false }; + } + } + + // 处理消息范围 + private async processMessageRange( + chatId: string, + startId: number, + endId: number, + targetPeer: any, + replyMsg: Api.Message, + showSource: boolean + ): Promise<{ total: number; success: number; lastForwardedMsg?: Api.Message; startSource: { chatId: string; messageId: number } | null; endSource: { chatId: string; messageId: number } | null }> { + let successCount = 0; + let totalProcessed = 0; + let lastForwardedMsg: Api.Message | undefined; + let startSource: { chatId: string; messageId: number } | null = null; + let endSource: { chatId: string; messageId: number } | null = null; + + // 确保startId <= endId + const actualStart = Math.min(startId, endId); + const actualEnd = Math.max(startId, endId); + const totalMessages = actualEnd - actualStart + 1; + + await this.safeEditMessage(replyMsg, `🔄 开始处理消息范围 ${actualStart}-${actualEnd} (共${totalMessages}条)...`, true); + + for (let msgId = actualStart; msgId <= actualEnd; msgId++) { + totalProcessed++; + const progress = `[${totalProcessed}/${totalMessages}] `; + + try { + await this.safeEditMessage(replyMsg, `${progress}🔍 获取消息 ${msgId}...`, true); + const sourceMsg = await this.getMessage(chatId, msgId); + + if (!sourceMsg) { + await this.safeEditMessage(replyMsg, `${progress}⏭️ 消息 ${msgId} 不存在,跳过`, true); + continue; + } + + await this.safeEditMessage(replyMsg, `${progress}🔄 处理消息 ${msgId}...`, true); + const result = await this.processMessage( + sourceMsg, + targetPeer, + replyMsg, + chatId, + msgId, + progress + ); + + if (result.success) { + successCount++; + if (result.forwardedMsg) { + lastForwardedMsg = result.forwardedMsg; + } + if (!startSource && result.source) { + startSource = result.source; + } + if (result.source) { + endSource = result.source; + } + await this.safeEditMessage(replyMsg, `${progress}✅ 消息 ${msgId} 处理完成`, true); + } else { + await this.safeEditMessage(replyMsg, `${progress}❌ 消息 ${msgId} 处理失败`, true); + } + } catch (error: any) { + await this.safeEditMessage(replyMsg, `${progress}❌ 消息 ${msgId} 处理出错: ${htmlEscape(error.message || "未知错误")}`, true); + } + + // 延迟以避免触发限制 + if (msgId < actualEnd) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + if (showSource && lastForwardedMsg) { + await this.sendRangeSourceSummary(targetPeer, lastForwardedMsg, startSource, endSource); + } + + return { total: totalProcessed, success: successCount, lastForwardedMsg, startSource, endSource }; + } + + private async processMessageRangeToLocal( + chatId: string, + startId: number, + endId: number, + replyMsg: Api.Message + ): Promise<{ total: number; saved: number; skipped: number; failed: number; savedFiles: LocalSavedFile[] }> { + let savedCount = 0; + let skippedCount = 0; + let failedCount = 0; + let totalProcessed = 0; + const savedFiles: LocalSavedFile[] = []; + + const actualStart = Math.min(startId, endId); + const actualEnd = Math.max(startId, endId); + const totalMessages = actualEnd - actualStart + 1; + + await this.safeEditMessage(replyMsg, `🔄 开始保存消息范围 ${actualStart}-${actualEnd} 到本地 (共${totalMessages}条)...`, true); + + for (let msgId = actualStart; msgId <= actualEnd; msgId++) { + totalProcessed++; + const progress = `[${totalProcessed}/${totalMessages}] `; + + try { + await this.safeEditMessage(replyMsg, `${progress}🔍 获取消息 ${msgId}...`, true); + const sourceMsg = await this.getMessage(chatId, msgId); + + if (!sourceMsg) { + skippedCount++; + await this.safeEditMessage(replyMsg, `${progress}⏭️ 消息 ${msgId} 不存在,跳过`, true); + continue; + } + + const result = await this.processMessageToLocal(sourceMsg, replyMsg, chatId, msgId, progress); + if (result.success && result.savedFile) { + savedCount++; + savedFiles.push(result.savedFile); + } else if (result.skipped) { + skippedCount++; + } else { + failedCount++; + } + } catch (error: any) { + failedCount++; + await this.safeEditMessage(replyMsg, `${progress}❌ 消息 ${msgId} 本地保存出错: ${htmlEscape(error.message || "未知错误")}`, true); + } + + if (msgId < actualEnd) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + return { total: totalProcessed, saved: savedCount, skipped: skippedCount, failed: failedCount, savedFiles }; + } + + private async writeLocalSaveIndex(savedFiles: LocalSavedFile[]): Promise<{ indexPath: string; relativeIndexPath: string } | null> { + if (savedFiles.length === 0) { + return null; + } + + const rootDir = this.getLocalSaveRoot(); + await fs.mkdir(rootDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const indexPath = this.buildUniquePath(rootDir, `index_${timestamp}.json`); + const groupedChats = new Map(); + + for (const savedFile of savedFiles) { + const current = groupedChats.get(savedFile.sourceChatId) || { + chatTitle: savedFile.sourceChatTitle, + items: [], + }; + current.items.push(savedFile); + groupedChats.set(savedFile.sourceChatId, current); + } + + const payload = { + generatedAt: new Date().toISOString(), + totalFiles: savedFiles.length, + chats: Array.from(groupedChats.entries()).map(([chatId, group]) => ({ + chatId, + chatTitle: group.chatTitle, + count: group.items.length, + files: group.items + .slice() + .sort((a, b) => a.sourceMessageId - b.sourceMessageId) + .map((item) => ({ + sourceMessageId: item.sourceMessageId, + sourceLink: item.sourceLink, + mediaType: item.mediaType, + groupedId: item.groupedId || null, + filePath: item.relativeFilePath, + metadataPath: item.relativeMetadataPath, + })), + })), + }; + + await fs.writeFile(indexPath, JSON.stringify(payload, null, 2), "utf8"); + return { + indexPath, + relativeIndexPath: path.relative(process.cwd(), indexPath) || indexPath, + }; + } + + // 主要处理函数 + private async handleCommand(msg: Api.Message): Promise { + try { + const client = await getGlobalClient(); + if (!client) { + await this.safeEditMessage(msg, "❌ 客户端未初始化", true); + return; + } + + const userId = msg.senderId?.toString() || "unknown"; + const text = msg.text || ""; + const parts = text.trim().split(/\s+/); + + // 检查是否有回复消息 + const replyMsg = await msg.getReplyMessage(); + + // 处理source子命令 + if (parts.length >= 2 && parts[1].toLowerCase() === "source") { + const config = await this.getUserConfig(userId); + + if (parts.length === 2) { + // 查看当前状态 + const status = config.showSource ? "开启 ✅" : "关闭 ❌"; + await this.safeEditMessage(msg, `📊 来源显示功能: ${status}\n\n`, true); + return; + } + + const action = parts[2].toLowerCase(); + if (action === "on") { + await this.setUserConfig(userId, { showSource: true }); + await this.safeEditMessage(msg, "✅ 已开启来源显示功能\n\n转发消息后,将回复一条包含原消息链接的来源消息。", true); + } else if (action === "off") { + await this.setUserConfig(userId, { showSource: false }); + await this.safeEditMessage(msg, "❌ 已关闭来源显示功能\n\n转发消息后,将不再显示来源链接。", true); + } else { + await this.safeEditMessage(msg, `❌ 无效的参数\n\n使用: ${mainPrefix}save source on/off`, true); + } + return; + } + + // 处理子命令 + if (parts.length >= 2 && parts[1].toLowerCase() === "to") { + if (parts.length < 3) { + await this.safeEditMessage(msg, `❌ 请指定转发目标\n\n💡 示例:\n${mainPrefix}save to @username\n${mainPrefix}save to -123456780\n${mainPrefix}save to me\n${mainPrefix}save to local`, true); + return; + } + + const target = parts.slice(2).join(" "); + await this.setUserConfig(userId, { target }); + await this.safeEditMessage(msg, `✅ 已设置默认转发目标为: ${htmlEscape(target)}`, true); + return; + } + + if (parts.length >= 2 && parts[1].toLowerCase() === "target") { + const config = await this.getUserConfig(userId); + await this.safeEditMessage(msg, `📌 当前默认转发目标: ${htmlEscape(config.target)}`, true); + return; + } + + // 处理帮助命令 + if (parts.length === 2 && (parts[1] === 'help' || parts[1] === 'h')) { + await this.safeEditMessage(msg, help_text, true); + return; + } + + // 获取用户配置 + const config = await this.getUserConfig(userId); + let target = config.target; + const showSource = config.showSource; + + // 解析链接和临时目标 + const links: string[] = []; + let tempTarget: string | null = null; + let rangeMode = false; + let rangeInfo: { chatId: string; startId: number; endId: number } | null = null; + + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + + // 检查是否是范围模式(包含|) + if (part.includes('|') && (part.startsWith('http') || part.startsWith('t.me'))) { + const rangeParts = part.split('|'); + if (rangeParts.length === 2) { + const link1 = this.parseMessageLink(rangeParts[0]); + const link2 = this.parseMessageLink(rangeParts[1]); + + if (link1 && link2 && link1.chatId === link2.chatId) { + rangeMode = true; + rangeInfo = { + chatId: link1.chatId, + startId: link1.messageId, + endId: link2.messageId + }; + break; + } + } + } else if (part.startsWith('http') || part.startsWith('t.me')) { + links.push(part); + } else if (i === parts.length - 1 && links.length > 0) { + tempTarget = part; + } + } + + if (tempTarget) target = tempTarget; + const localTarget = this.isLocalTarget(target); + + // 如果既没有链接也没有回复消息,显示帮助 + if (links.length === 0 && !replyMsg && !rangeMode) { + await this.safeEditMessage(msg, help_text, true); + return; + } + + // 获取目标对话实体 + let targetPeer: any; + if (!localTarget) { + try { + targetPeer = await client.getInputEntity(target); + } catch (error) { + await this.safeEditMessage(msg, `❌ 无法访问目标对话: ${htmlEscape(target)}`, true); + return; + } + } + + // 范围模式处理 + if (rangeMode && rangeInfo) { + await this.safeEditMessage(msg, `🔍 进入范围模式: ${rangeInfo.startId}-${rangeInfo.endId}`, true); + if (localTarget) { + const result = await this.processMessageRangeToLocal( + rangeInfo.chatId, + rangeInfo.startId, + rangeInfo.endId, + msg + ); + const indexInfo = await this.writeLocalSaveIndex(result.savedFiles); + const directorySummary = this.formatLocalDirectorySummary(result.savedFiles); + + await this.safeEditMessage( + msg, + `✅ 范围本地保存完成\n已保存: ${result.saved}\n跳过: ${result.skipped}\n失败: ${result.failed}\n${directorySummary}${indexInfo ? `\n索引: ${htmlEscape(indexInfo.relativeIndexPath)}` : ""}\n每个媒体旁已生成同名 .json 来源元数据`, + true + ); + } else { + const result = await this.processMessageRange( + rangeInfo.chatId, + rangeInfo.startId, + rangeInfo.endId, + targetPeer, + msg, + showSource + ); + + await this.safeEditMessage(msg, `✅ 范围处理完成\n成功: ${result.success}/${result.total} 条消息`, true); + } + return; + } + + // 处理消息 + const messagesToProcess: Array<{ + chatId: string; + messageId: number; + groupedId?: string; + isMediaGroup?: boolean; + }> = []; + + // 链接模式 + if (links.length > 0) { + for (const link of links) { + const linkInfo = this.parseMessageLink(link); + if (!linkInfo) { + await this.safeEditMessage(msg, `❌ 无效的消息链接: ${htmlEscape(link)}`, true); + return; + } + + const sourceMsg = await this.getMessage(linkInfo.chatId, linkInfo.messageId); + if (sourceMsg) { + if (sourceMsg.groupedId) { + const existingGroup = messagesToProcess.find(m => + m.groupedId === sourceMsg.groupedId?.toString() + ); + if (!existingGroup) { + messagesToProcess.push({ + chatId: linkInfo.chatId, + messageId: linkInfo.messageId, + groupedId: sourceMsg.groupedId?.toString(), + isMediaGroup: true + }); + } + } else { + messagesToProcess.push({ + chatId: linkInfo.chatId, + messageId: linkInfo.messageId + }); + } + } else { + await this.safeEditMessage(msg, `❌ 无法获取消息: ${link}`, true); + return; + } + } + } + // 回复模式 + else if (replyMsg) { + messagesToProcess.push({ + chatId: replyMsg.peerId?.toString() || "", + messageId: replyMsg.id, + groupedId: replyMsg.groupedId?.toString() + }); + } + + if (messagesToProcess.length === 0) { + await this.safeEditMessage(msg, "❌ 未找到要转发的消息", true); + return; + } + + const total = messagesToProcess.length; + const mediaGroups = new Map(); + let successCount = 0; + let skippedCount = 0; + let failedCount = 0; + let lastForwardedMsg: Api.Message | undefined; + const sourceSummaries: Array<{ chatId: string; messageId: number }> = []; + const localSavedFiles: LocalSavedFile[] = []; + + await this.safeEditMessage(msg, localTarget ? `🔄 开始保存 ${total} 个消息/媒体组到本地...` : `🔄 开始处理 ${total} 个消息/媒体组...`, true); + + for (let i = 0; i < messagesToProcess.length; i++) { + const messageInfo = messagesToProcess[i]; + const progress = total > 1 ? `[${i + 1}/${total}] ` : ""; + + if (messageInfo.isMediaGroup && messageInfo.groupedId) { + if (mediaGroups.has(messageInfo.groupedId)) continue; + mediaGroups.set(messageInfo.groupedId, true); + + // 简化处理:对于媒体组,逐个消息处理 + const client = await getGlobalClient(); + const peer = await client.getInputEntity(messageInfo.chatId); + const searchIds: number[] = []; + + for (let j = 0; j <= 60; j++) { + const id = messageInfo.messageId - 30 + j; + if (id > 0) searchIds.push(id); + } + + const messages = await client.getMessages(peer, { ids: searchIds }); + const groupMessages = messages.filter((msg): msg is Api.Message => + msg && (msg as Api.Message).groupedId?.toString() === messageInfo.groupedId + ); + + groupMessages.sort((a, b) => a.id - b.id); + const localGroupDirName = localTarget ? `group_${messageInfo.groupedId}` : undefined; + + for (let j = 0; j < groupMessages.length; j++) { + const groupMsg = groupMessages[j]; + const groupProgress = `[${i + 1}/${total}] [${j + 1}/${groupMessages.length}] `; + const result = localTarget + ? await this.processMessageToLocal(groupMsg, msg, messageInfo.chatId, groupMsg.id, groupProgress, { + groupDirName: localGroupDirName, + quietSuccess: total > 1, + }) + : await this.processMessage(groupMsg, targetPeer, msg, messageInfo.chatId, groupMsg.id, groupProgress); + + if (result.success) { + successCount++; + if (!localTarget && result.forwardedMsg) { + lastForwardedMsg = result.forwardedMsg; + } + if (localTarget && result.savedFile) { + localSavedFiles.push(result.savedFile); + } + if (result.source) { + sourceSummaries.push(result.source); + } + } else if (localTarget && result.skipped) { + skippedCount++; + } else if (localTarget) { + failedCount++; + } + + if (j < groupMessages.length - 1) { + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + } else { + const sourceMsg = await this.getMessage(messageInfo.chatId, messageInfo.messageId); + if (sourceMsg) { + const result = localTarget + ? await this.processMessageToLocal(sourceMsg, msg, messageInfo.chatId, messageInfo.messageId, progress, { + quietSuccess: total > 1, + }) + : await this.processMessage(sourceMsg, targetPeer, msg, messageInfo.chatId, messageInfo.messageId, progress); + if (result.success) { + successCount++; + if (!localTarget && result.forwardedMsg) { + lastForwardedMsg = result.forwardedMsg; + } + if (localTarget && result.savedFile) { + localSavedFiles.push(result.savedFile); + } + if (result.source) { + sourceSummaries.push(result.source); + } + } else if (localTarget && result.skipped) { + skippedCount++; + } else if (localTarget) { + failedCount++; + } + } + } + + if (i < messagesToProcess.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + if (!localTarget && showSource && lastForwardedMsg && sourceSummaries.length > 0) { + if (sourceSummaries.length === 1) { + const source = sourceSummaries[0]; + await this.sendSingleSourceMessage(targetPeer, source.chatId, source.messageId, lastForwardedMsg, total === 1 ? msg : undefined); + } else { + await this.sendBatchSourceSummary(targetPeer, lastForwardedMsg, sourceSummaries); + } + } + + if (localTarget) { + const indexInfo = await this.writeLocalSaveIndex(localSavedFiles); + const directorySummary = this.formatLocalDirectorySummary(localSavedFiles); + if (localSavedFiles.length === 1) { + const savedFile = localSavedFiles[0]; + await this.safeEditMessage( + msg, + `✅ 本地保存完成\n文件: ${htmlEscape(savedFile.relativeFilePath)}\n元数据: ${htmlEscape(savedFile.relativeMetadataPath)}\n${directorySummary}${indexInfo ? `\n索引: ${htmlEscape(indexInfo.relativeIndexPath)}` : ""}`, + true + ); + } else { + await this.safeEditMessage( + msg, + `✅ 本地保存完成\n已保存: ${successCount}\n跳过: ${skippedCount}\n失败: ${failedCount}\n${directorySummary}${indexInfo ? `\n索引: ${htmlEscape(indexInfo.relativeIndexPath)}` : ""}\n每个媒体旁已生成同名 .json 来源元数据`, + true + ); + } + } else if (total > 1) { + await this.safeEditMessage(msg, `✅ 批量处理完成\n成功处理 ${successCount}/${total} 个消息/媒体组`, true); + } + + } catch (error: any) { + console.error(`save命令执行失败:`, error); + await this.safeEditMessage(msg, `❌ 执行失败: ${htmlEscape(error.message || "未知错误")}`, true); + } + } + + cmdHandlers = { + save: async (msg: Api.Message): Promise => { + await this.handleCommand(msg); + } + }; +} + +export default new PrometheusPlugin(); From 16aedd2d08a16abd05c450471fc0f3e19941e793 Mon Sep 17 00:00:00 2001 From: TiaraBasori <131457234+TiaraBasori@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:35:50 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=94=B9=E5=8A=A8=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20bgp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 3 +- ai/ai.ts | 5985 ++++++++++++++++++++++---------------- autodel/autodel.ts | 7 +- autorepeat/autorepeat.ts | 9 + bgp/bgp.ts | 489 ++++ convert/convert.ts | 13 +- hitokoto/hitokoto.ts | 5 +- komari/komari.ts | 13 +- lottery/lottery.ts | 7 +- lu_bs/lu_bs.ts | 2 +- music/music.ts | 14 + music_bot/music_bot.ts | 2 +- plugins.json | 12 +- shift/shift.ts | 45 +- ssh/ssh.ts | 5 +- yt-dlp/yt-dlp.ts | 16 +- 16 files changed, 4150 insertions(+), 2477 deletions(-) create mode 100644 bgp/bgp.ts diff --git a/README.md b/README.md index fb78c261..18459e79 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ tpm i <插件名> - `autodelcmd` - 自动删除命令消息 - `autorepeat` - 智能自动复读机 - `banana` - Nano-Banana 图像编辑 +- `bgp` - BGP路由图查询工具 - `bin` - 卡头检测 - `bizhi` - 发送一张壁纸 - `botmzt` - 随机获取写真图片 @@ -87,12 +88,12 @@ tpm i <插件名> - `pmcaptcha` - 简单防私聊 - `portball` - 临时禁言 - `premium` - 群组大会员统计 -- `prometheus` - 突破Telegram保存限制 - `q` - 消息引用生成贴纸 - `qr` - QR 二维码 - `rate` - 货币实时汇率查询与计算 - `restore_pin` - 恢复群组被取消的置顶消息 - `rev` - 反转你的消息 +- `save` - 本地保存插件 - `search` - 频道消息搜索 - `service` - systemd服务状态查看 - `shift` - 智能消息转发系统 diff --git a/ai/ai.ts b/ai/ai.ts index 5ac4c8bc..0568abad 100644 --- a/ai/ai.ts +++ b/ai/ai.ts @@ -1,835 +1,482 @@ -/** - * TeleBox AI 插件(完美整合版) - * 兼容 OpenAI / Gemini / Claude / 火山 等标准接口 - * 功能:对话、搜索、识图、生图、TTS、语音回答、全局 Prompt 预设、上下文记忆、 Telegraph 长文等 - * 用法:.ai 或 .ai chat|search|image|tts|audio|searchaudio|prompt|config|model|... - * 2025-05 最终优化版 - */ import { Plugin } from "@utils/pluginBase"; -import { getPrefixes } from "@utils/pluginManager"; import { Api } from "teleproto"; -import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import { getPrefixes } from "@utils/pluginManager"; +import type { Low } from "lowdb"; import { JSONFilePreset } from "lowdb/node"; +import { createDirectoryInAssets } from "@utils/pathHelpers"; +import { TelegramFormatter } from "@utils/telegramFormatter"; +import { TelegraphFormatter } from "@utils/telegraphFormatter"; +import { execFile } from "child_process"; +import fs from "fs"; import * as path from "path"; -import * as fs from "fs"; +import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"; import sharp from "sharp"; -import { createDirectoryInAssets } from "@utils/pathHelpers"; +import http from "http"; +import https from "https"; +import { promisify } from "util"; + +interface ProviderConfig { + tag: string; + url: string; + key: string; +} -/* ---------- 类型定义 ---------- */ -type Provider = { - apiKey: string; - baseUrl: string; - compatauth?: Compat; - authMethod?: AuthMethod; - authConfig?: AuthConfig; -}; -type Compat = "openai" | "gemini" | "claude"; -type Models = { chat: string; search: string; image: string; tts: string }; -type Telegraph = { - enabled: boolean; - limit: number; - token: string; - posts: { title: string; url: string; createdAt: string }[]; -}; -type VoiceConfig = { gemini: string; openai: string }; -type DB = { - dataVersion?: number; - providers: Record; - modelCompat?: Record>; - modelCatalog?: { map: Record; updatedAt?: string }; - models: Models; - contextEnabled: boolean; +interface TelegraphItem { + url: string; + title: string; + createdAt: string; +} + +interface DB { + configs: Record; + currentChatTag: string; + currentChatModel: string; + currentSearchTag: string; + currentSearchModel: string; + currentImageTag: string; + currentImageModel: string; + currentVideoTag: string; + currentVideoModel: string; + imagePreview: boolean; + videoPreview: boolean; + videoAudio: boolean; + videoDuration: number; + prompt: string; collapse: boolean; - telegraph: Telegraph; - voices?: VoiceConfig; - histories: Record; - histMeta?: Record; - presetPrompt?: string; // 全局 Prompt 预设 - timeout?: number; // 全局超时时间(毫秒) - maxTokens?: number; // 最大输出 token 数 - linkPreview?: boolean; // 链接即时预览开关 + timeout: number; + telegraphToken: string; + telegraph: { + enabled: boolean; + limit: number; + list: TelegraphItem[]; + }; +} -}; +type AIContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; -/* ---------- 常量 ---------- */ -const MAX_MSG = 4096; -const PAGE_EXTRA = 48; -const WRAP_EXTRA_COLLAPSED = 64; -const HISTORY_MAX_ITEMS = 50; -const HISTORY_MAX_BYTES = 64 * 1024; -const MODEL_REFRESH_DEBOUNCE_MS = 2000; -const DEFAULT_TIMEOUT_MS = 30000; // 默认超时 30 秒 -const MAX_TIMEOUT_MS = 600000; // 最大超时 10 分钟 -const DEFAULT_MAX_TOKENS = 16384; // 默认最大输出 token(约8000中文字) -const GEMINI_VOICES = [ - "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", - "Callirhoe", "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", - "Despina", "Erinome", "Algenib", "Rasalgethi", "Laomedeia", "Achernar", - "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi", - "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafar" -] as const; -const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const; - -/* ---------- 工具函数 ---------- */ -// 动态获取命令前缀 -const prefixes = getPrefixes(); -const mainPrefix = prefixes[0]; - -const trimBase = (u: string) => u.replace(/\/$/, ""); -const html = (t: string) => - t.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -const shortenUrlForDisplay = (u: string) => { - try { - const url = new URL(u); - const host = url.hostname; - const path = url.pathname && url.pathname !== "/" ? url.pathname : ""; - let text = host + path; - if (text.length > 60) text = text.slice(0, 45) + "…" + text.slice(-10); - return text || u; - } catch { - return u.length > 60 ? u.slice(0, 45) + "…" + u.slice(-10) : u; - } -}; -const nowISO = () => new Date().toISOString(); -const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); -const shouldRetry = (err: any): boolean => { - const s = err?.response?.status; - const code = err?.code; - return ( - s === 429 || s === 500 || s === 502 || s === 503 || s === 504 || - code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND" || - !!(err?.isAxiosError && !err?.response) - ); -}; -const axiosWithRetry = async ( - config: AxiosRequestConfig, - tries = 2, - backoffMs = 500 -): Promise> => { - let attempt = 0; - let lastErr: any; - const configuredTimeout = Store.data.timeout || DEFAULT_TIMEOUT_MS; - while (attempt <= tries) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), configuredTimeout); - try { - const baseConfig: AxiosRequestConfig = { - timeout: configuredTimeout, - signal: controller.signal, - ...config - }; - const result = await axios(baseConfig); - clearTimeout(timeoutId); - return result; - } catch (err: any) { - clearTimeout(timeoutId); - lastErr = err; - if (err.name === 'CanceledError' || err.code === 'ERR_CANCELED') { - throw new Error(`请求超时(${configuredTimeout / 1000}秒)`); - } - if (attempt >= tries || !shouldRetry(err)) throw err; - const jitter = Math.floor(Math.random() * 200); - await sleep(backoffMs * Math.pow(2, attempt) + jitter); - attempt++; - } - } - throw lastErr; -}; +interface AIImage { + data?: Buffer; + url?: string; + mimeType: string; +} + +interface AIVideo { + data?: Buffer; + url?: string; + mimeType: string; +} -/* ---------- 原子 JSON 写入 ---------- */ -const atomicWriteJSON = async (file: string, data: any) => { - const dir = path.dirname(file); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const tmp = file + ".tmp"; - await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); - await fs.promises.rename(tmp, file); +type ResolvedImageData = { + data: Buffer; + mimeType: string; }; -/* ---------- 通用鉴权 ---------- */ -enum AuthMethod { - BEARER_TOKEN = "bearer_token", - API_KEY_HEADER = "api_key_header", - QUERY_PARAM = "query_param", - BASIC_AUTH = "basic_auth", - CUSTOM_HEADER = "custom_header" +interface AbortToken { + readonly aborted: boolean; + readonly reason?: string; + readonly signal: AbortSignal; + abort(reason?: string): void; + throwIfAborted(): void; } -interface AuthConfig { - method: AuthMethod; - apiKey: string; - headerName?: string; - paramName?: string; - username?: string; - password?: string; + +interface FeatureHandler { + readonly name: string; + readonly command: string; + readonly description: string; + execute(msg: Api.Message, args: string[], prefixes: string[]): Promise; } -class UniversalAuthHandler { - static buildAuthHeaders(config: AuthConfig): Record { - const headers: Record = {}; - switch (config.method) { - case AuthMethod.BEARER_TOKEN: - headers["Authorization"] = `Bearer ${config.apiKey}`; - break; - case AuthMethod.API_KEY_HEADER: - headers[config.headerName || "X-API-Key"] = config.apiKey; - break; - case AuthMethod.CUSTOM_HEADER: - if (config.headerName) headers[config.headerName] = config.apiKey; - break; - case AuthMethod.BASIC_AUTH: - headers["Authorization"] = `Basic ${Buffer.from( - `${config.username || config.apiKey}:${config.password || ""}` - ).toString("base64")}`; - break; - } - return headers; - } - static buildAuthParams(config: AuthConfig): Record { - const params: Record = {}; - if (config.method === AuthMethod.QUERY_PARAM) { - params[config.paramName || "key"] = config.apiKey; - } - return params; - } - static detectAuthMethod(baseUrl: string): AuthMethod { - const url = baseUrl.toLowerCase(); - if (url.includes("generativelanguage.googleapis.com") || url.includes("aiplatform.googleapis.com")) - return AuthMethod.QUERY_PARAM; - if (url.includes("anthropic.com")) return AuthMethod.API_KEY_HEADER; - if (url.includes("aip.baidubce.com")) return AuthMethod.QUERY_PARAM; - return AuthMethod.BEARER_TOKEN; - } + +interface Middleware { + process( + input: T, + next: (input: T, token?: AbortToken) => Promise, + token?: AbortToken + ): Promise; } -/* ---------- 统一鉴权构建 ---------- */ -const buildAuthAttempts = (p: Provider, extraHeaders: Record = {}) => { - if (p.authConfig) { - const headers = { ...UniversalAuthHandler.buildAuthHeaders(p.authConfig), ...extraHeaders }; - const params = { ...UniversalAuthHandler.buildAuthParams(p.authConfig) }; - return [{ headers, params }]; - } - const detected = UniversalAuthHandler.detectAuthMethod(p.baseUrl); - const cfg: AuthConfig = { - method: detected, - apiKey: p.apiKey, - headerName: detected === AuthMethod.API_KEY_HEADER ? "x-api-key" : undefined, - paramName: detected === AuthMethod.QUERY_PARAM ? "key" : undefined - }; - const headers = { ...UniversalAuthHandler.buildAuthHeaders(cfg), ...extraHeaders }; - const params = { ...UniversalAuthHandler.buildAuthParams(cfg) }; - return [{ headers, params }]; -}; -const tryPostJSON = async (url: string, body: any, attempts: Array<{ headers?: any; params?: any }>) => { - let lastErr: any; - for (const a of attempts) { - try { - const r = await axiosWithRetry({ method: "POST", url, data: body, ...(a || {}) }); - return r.data; - } catch (err) { - lastErr = err; - } - } - throw lastErr; -}; +const execFileAsync = promisify(execFile); -/* ---------- lowdb 封装 ---------- */ -class Store { - static db: any = null; - static data: DB = { - providers: {}, - models: { chat: "", search: "", image: "", tts: "" }, - contextEnabled: false, - collapse: false, - telegraph: { enabled: false, limit: 0, token: "", posts: [] }, - voices: { gemini: "Kore", openai: "alloy" }, - histories: {}, - presetPrompt: "", - timeout: DEFAULT_TIMEOUT_MS - }; - static baseDir = ""; - static file = ""; - static async init() { - if (this.db) return; - this.baseDir = createDirectoryInAssets("ai"); - this.file = path.join(this.baseDir, "config.json"); - this.db = await JSONFilePreset(this.file, this.data); - this.data = this.db.data; - const d: any = this.data; - // 默认值填充 - const defaults: Record = { - dataVersion: 5, providers: {}, modelCompat: {}, modelCatalog: { map: {}, updatedAt: undefined }, - models: { chat: "", search: "", image: "", tts: "" }, contextEnabled: false, collapse: false, - telegraph: { enabled: false, limit: 0, token: "", posts: [] }, voices: { gemini: "Kore", openai: "alloy" }, - histories: {}, histMeta: {}, presetPrompt: "", timeout: DEFAULT_TIMEOUT_MS, maxTokens: DEFAULT_MAX_TOKENS - }; - for (const [k, v] of Object.entries(defaults)) if (d[k] === undefined || d[k] === null) d[k] = v; - if (d.dataVersion < 3) { try { await refreshModelCatalog(true); } catch { } d.dataVersion = 5; } - // 确保超时值在有效范围内 - if (d.timeout > MAX_TIMEOUT_MS) d.timeout = MAX_TIMEOUT_MS; - if (d.timeout < 10000) d.timeout = DEFAULT_TIMEOUT_MS; - await this.writeSoon(); - } - static async write() { await atomicWriteJSON(this.file, this.data); } - static writeSoonDelay = 300; - static _writeTimer: NodeJS.Timeout | null = null; - static async writeSoon(): Promise { - if (this._writeTimer) clearTimeout(this._writeTimer); - this._writeTimer = setTimeout(async () => { - try { await atomicWriteJSON(this.file, this.data); } finally { this._writeTimer = null; } - }, this.writeSoonDelay); - return Promise.resolve(); - } -} +type AuthMode = "bearer" | "query-key"; -/* ---------- 消息分片 & 折叠 ---------- */ -const applyWrap = (s: string, collapse?: boolean) => { - if (!collapse) return s; - if (/|\/)\/?>/i.test(s)) return s; - return `${s}`; -}; -const buildChunks = (text: string, collapse?: boolean, postfix?: string) => { - const WRAP_EXTRA = collapse ? WRAP_EXTRA_COLLAPSED : 0; - const parts = splitMessage(text, PAGE_EXTRA + WRAP_EXTRA); - if (parts.length === 0) return []; - if (parts.length === 1) return [applyWrap(parts[0], collapse) + (postfix || "")]; - const total = parts.length; - const chunks: string[] = []; - for (let i = 0; i < total; i++) { - const isLast = i === total - 1; - const header = `📄 (${i + 1}/${total})\n\n`; - const body = header + parts[i]; - const wrapped = applyWrap(body, collapse) + (isLast ? (postfix || "") : ""); - chunks.push(wrapped); - } - return chunks; -}; -const sendLong = async (msg: Api.Message, text: string, opts?: { collapse?: boolean }, postfix?: string) => { - const chunks = buildChunks(text, opts?.collapse, postfix); - if (chunks.length === 0) return; - if (chunks.length === 1) { await msg.edit({ text: chunks[0], parseMode: "html" }); return; } - await msg.edit({ text: chunks[0], parseMode: "html" }); - if (msg.client) { - const peer = msg.peerId; - for (let i = 1; i < chunks.length; i++) await msg.client.sendMessage(peer, { message: chunks[i], parseMode: "html" }); - } else { - for (let i = 1; i < chunks.length; i++) await msg.reply({ message: chunks[i], parseMode: "html" }); - } -}; -const sendLongReply = async (msg: Api.Message, replyToId: number, text: string, opts?: { collapse?: boolean }, postfix?: string) => { - const chunks = buildChunks(text, opts?.collapse, postfix); - if (!msg.client) return; - const peer = msg.peerId; - for (const chunk of chunks) await msg.client.sendMessage(peer, { message: chunk, parseMode: "html", replyTo: replyToId }); -}; -const extractText = (m: Api.Message | null | undefined) => { - if (!m) return ""; - const anyM: any = m; - return (anyM.message || anyM.text || anyM.caption || ""); -}; -/** - * 提取引用文本或被回复消息的文本 - * 优先级:1. 引用文本 (quoteText) 2. 被回复消息的内容 - * @param msg 当前消息 - * @param replyMsg 被回复的消息(通过 getReplyMessage 获取) - * @returns 引用文本或被回复消息的文本 - */ -const extractQuoteOrReplyText = (msg: Api.Message, replyMsg: Api.Message | null | undefined): string => { - // 优先使用引用文本 (Telegram 的 quote reply 功能) - const quoteText = (msg.replyTo as any)?.quoteText; - if (quoteText && typeof quoteText === "string" && quoteText.trim()) { - return quoteText.trim(); - } - // 回退到被回复消息的内容 - return extractText(replyMsg); -}; -const splitMessage = (text: string, reserve = 0) => { - const limit = Math.max(1, MAX_MSG - Math.max(0, reserve)); - if (text.length <= limit) return [text]; - const parts: string[] = []; - let cur = ""; - for (const line of text.split("\n")) { - if (line.length > limit) { - if (cur) { parts.push(cur); cur = ""; } - for (let i = 0; i < line.length; i += limit) parts.push(line.slice(i, i + limit)); - continue; - } - const next = cur ? cur + "\n" + line : line; - if (next.length > limit) { parts.push(cur); cur = line; } else { cur = next; } - } - if (cur) parts.push(cur); - return parts; -}; +type ProviderMode = "chat" | "search" | "image" | "video"; -/* ---------- 兼容类型检测 ---------- */ -const detectCompat = (model: string): Compat => { - const m = (model || "").toLowerCase(); - if (/\bclaude\b|anthropic/.test(m)) return "claude"; - if (/\bgemini\b|(^gemini-)|image-generation/.test(m)) return "gemini"; - if (/(^gpt-|gpt-4o|gpt-image|dall-e|^tts-1\b)/.test(m)) return "openai"; - return "openai"; -}; +type ProviderStrategy = + | "openai-rest" + | "gemini-rest" + | "doubao-rest" + | "gemini-image-rest" + | "gemini-video-rest"; -/* ---------- 模型目录 ---------- */ -const catalogInflight: { refreshing: boolean; lastPromise: Promise | null } = { refreshing: false, lastPromise: null }; -const getCompatFromCatalog = (model: string): Compat | null => { - const ml = String(model || "").toLowerCase(); - const map = Store.data.modelCatalog?.map || ({} as Record); - const v = (map as any)[ml] as Compat | undefined; - return v ?? null; -}; -const refreshModelCatalog = async (force = false): Promise => { - if (!force && catalogInflight.refreshing) return catalogInflight.lastPromise || Promise.resolve(); - catalogInflight.refreshing = true; - const work = (async () => { - try { - const entries = Object.entries(Store.data.providers || {}); - const merged: Record = {}; - for (const [, p] of entries) { - try { - const res = await listModelsByAnyCompat(p); - const mp: Record = (((res as any).modelMap) || {}) as Record; - for (const [k, v] of Object.entries(mp)) merged[k] = v; - } catch { } - } - const catalog = (Store.data.modelCatalog ??= { map: {}, updatedAt: undefined } as any); - (catalog as any).map = merged as any; - (catalog as any).updatedAt = nowISO(); - await Store.writeSoon(); - } finally { - catalogInflight.refreshing = false; - catalogInflight.lastPromise = null; - } - })(); - catalogInflight.lastPromise = work; - return work; -}; -/* ---------- 模型刷新防抖 ---------- */ -let refreshDebounceTimer: NodeJS.Timeout | null = null; -const debouncedRefreshModelCatalog = () => { - if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer); - refreshDebounceTimer = setTimeout(() => { - refreshDebounceTimer = null; - refreshModelCatalog(true).catch(() => { }); - }, MODEL_REFRESH_DEBOUNCE_MS); -}; -const compatResolving = new Map>(); -const resolveCompat = async (name: string, model: string, p: Provider): Promise => { - const ml = String(model || "").toLowerCase(); - const cat = getCompatFromCatalog(ml); - if (cat) return cat; - const mc = (Store.data.modelCompat && (Store.data.modelCompat as any)[name]) ? (Store.data.modelCompat as any)[name][ml] as Compat | undefined : undefined; - const byName = detectCompat(model); - if (mc) return mc; - setTimeout(() => { void refreshModelCatalog(false).catch(() => { }); }, 0); - const pending = compatResolving.get(name + "::" + ml) || compatResolving.get(name); - if (pending) return await pending; - const task = (async () => { - try { - const res = await listModelsByAnyCompat(p); - const primary: Compat | null = (res.compat as Compat) || null; - const map: Record = (((res as any).modelMap) || {}) as Record; - if (!Store.data.modelCompat) Store.data.modelCompat = {} as any; - if (!(Store.data.modelCompat as any)[name]) (Store.data.modelCompat as any)[name] = {} as any; - for (const [k, v] of Object.entries(map)) { - const cur = (Store.data.modelCompat as any)[name][k] as Compat | undefined; - if (cur !== v) (Store.data.modelCompat as any)[name][k] = v; - } - let comp: Compat = (Store.data.modelCompat as any)[name][ml] as Compat; - if (!comp) comp = (primary as Compat) || byName; - if (((Store.data.modelCompat as any)[name][ml] as Compat | undefined) !== comp) (Store.data.modelCompat as any)[name][ml] = comp; - const cat = (Store.data.modelCatalog ??= { map: {}, updatedAt: undefined } as any); - const catMap = (cat as any).map as Record; - for (const [k, v] of Object.entries(map)) if ((catMap as any)[k] !== v) (catMap as any)[k] = v as Compat; - if ((catMap as any)[ml] !== comp) (catMap as any)[ml] = comp; - (cat as any).updatedAt = nowISO(); - if (primary && p) if (p.compatauth !== primary) p.compatauth = primary; - await Store.writeSoon(); - return comp; - } catch { - const comp: Compat = byName; - if (!Store.data.modelCompat) Store.data.modelCompat = {} as any; - if (!(Store.data.modelCompat as any)[name]) (Store.data.modelCompat as any)[name] = {} as any; - if (!(Store.data.modelCompat as any)[name][ml]) (Store.data.modelCompat as any)[name][ml] = comp; - try { await Store.writeSoon(); } catch { } - setTimeout(() => { void refreshModelCatalog(false).catch(() => { }); }, 0); - return comp; - } finally { - compatResolving.delete(name + "::" + ml); - compatResolving.delete(name); - } - })(); - compatResolving.set(name + "::" + ml, task); - return await task; +type ModelMatchRule = { + type: "prefix" | "exact" | "includes" | "regex"; + value: string; }; -/* ---------- 错误映射 ---------- */ -const mapError = (err: any, ctx?: string): string => { - const s = err?.response?.status as number | undefined; - const body = err?.response?.data; - const raw = body?.error?.message || body?.message || err?.message || String(err); - let hint = ""; - if (s === 400) hint = "请求格式有误,可能是模型不支持当前参数或输入过长"; - else if (s === 401 || s === 403) hint = "认证失败,请检查 API Key 是否正确、是否有对应权限"; - else if (s === 404) hint = "接口不存在,请检查 BaseURL/兼容类型或服务商路由"; - else if (s === 429) hint = "请求过于频繁或额度受限,请稍后重试或调整速率"; - else if (typeof s === "number" && s >= 500) hint = "服务端异常,请稍后重试或更换服务商"; - else if (!s) hint = "网络异常,请检查网络或 BaseURL"; - const where = ctx ? `(${ctx})` : ""; - return `${raw}${hint ? "|" + hint : ""}${s ? `|HTTP ${s}` : ""}${where}`; +type ImageDefaults = { + size?: string; + quality?: string; + responseFormat?: "b64_json" | "url"; + extraParams?: Record; }; -/* ---------- 模型名规范化 ---------- */ -const normalizeModelName = (x: any): string => { - let s = String(x?.id || x?.slug || x?.name || x || ""); - s = s.trim(); - const q = s.indexOf("?"); - if (q >= 0) s = s.slice(0, q); - const h = s.indexOf("#"); - if (h >= 0) s = s.slice(0, h); - if (s.includes("/")) s = s.split("/").pop() || s; - return s.trim(); +type VideoDefaults = { + responseFormat?: "b64_json" | "url"; + extraParams?: Record; }; -/* ---------- 快捷选取 ---------- */ -const pick = (kind: keyof Models): { provider: string; model: string } | null => { - const s = Store.data.models[kind]; - if (!s) return null; - const i = s.indexOf(" "); - if (i <= 0) return null; - const provider = s.slice(0, i); - const model = s.slice(i + 1); - return { provider, model }; -}; -const providerOf = (name: string): Provider | null => Store.data.providers[name] || null; -const footer = (model: string, extra?: string) => { - const src = model.toLowerCase().includes("claude") ? "Anthropic Claude" : model.toLowerCase().includes("gemini") ? "Google Gemini" : "OpenAI"; - return `\n\nPowered by ${src}${extra ? " " + extra : ""}`; -}; -const ensureDir = () => { - if (!fs.existsSync(Store.baseDir)) fs.mkdirSync(Store.baseDir, { recursive: true }); +type ProviderModelRule = { + match: ModelMatchRule; + override: Partial; }; -/* ---------- 上下文隔离(用户+会话) ---------- */ -const contextKey = (msg: Api.Message): string => { - const chatId = String((msg.peerId as any)?.channelId || (msg.peerId as any)?.userId || (msg.peerId as any)?.chatId || "global"); - const userId = String((msg as any).senderId || (msg as any).fromId?.userId || "unknown"); - return `${userId}:${chatId}`; + +type ProviderModeConfig = { + strategy: ProviderStrategy; + endpoint?: string; + authMode?: AuthMode; + baseUrlType?: "origin" | "openai" | "gemini" | "raw"; + imageDefaults?: ImageDefaults; + videoDefaults?: VideoDefaults; + imageUrlPolicy?: "any" | "data-only"; + supportsEdit?: boolean; + modelRules?: ProviderModelRule[]; }; -const chatIdStr = (msg: Api.Message) => contextKey(msg); // 兼容别名 -const isGroupOrChannel = (msg: Api.Message): boolean => { - const peer = msg.peerId; - return (peer as any)?.className === "PeerChannel" || (peer as any)?.className === "PeerChat"; + +type ProviderProfile = { + id: string; + authMode?: AuthMode; + modes: Partial>; }; -const histFor = (id: string) => Store.data.histories[id] || []; -const HISTORY_GLOBAL_MAX_SESSIONS = 200; -const HISTORY_GLOBAL_MAX_BYTES = 2 * 1024 * 1024; -const pruneGlobalHistories = () => { - const ids = Object.keys(Store.data.histories || {}); - if (!ids.length) return; - const meta = (Store.data.histMeta || {}) as Record; - const sizeOfItem = (x: { role: string; content: string }) => Buffer.byteLength(`${x.role}:${x.content}`); - const sizeOfHist = (arr: { role: string; content: string }[]) => arr.reduce((t, x) => t + sizeOfItem(x), 0); - let totalBytes = 0; - for (const id of ids) totalBytes += sizeOfHist(Store.data.histories[id] || []); - if (ids.length <= HISTORY_GLOBAL_MAX_SESSIONS && totalBytes <= HISTORY_GLOBAL_MAX_BYTES) return; - const sorted = ids.sort((a, b) => { - const ta = Date.parse((meta[a]?.lastAt) || "1970-01-01T00:00:00.000Z"); - const tb = Date.parse((meta[b]?.lastAt) || "1970-01-01T00:00:00.000Z"); - return ta - tb; - }); - while ((sorted.length > HISTORY_GLOBAL_MAX_SESSIONS || totalBytes > HISTORY_GLOBAL_MAX_BYTES) && sorted.length) { - const victim = sorted.shift()!; - const arr = Store.data.histories[victim] || []; - totalBytes -= sizeOfHist(arr); - delete Store.data.histories[victim]; - if (Store.data.histMeta) delete Store.data.histMeta[victim]; - } + +type VideoImageMode = "auto" | "reference" | "first" | "firstlast"; + +type ChatContext = { + providerConfig: ProviderConfig; + model: string; + config: DB; + modeConfig: ProviderModeConfig; + question: string; + images: AIContentPart[]; + token?: AbortToken; }; -const pushHist = (id: string, role: string, content: string) => { - if (!Store.data.histories[id]) Store.data.histories[id] = []; - Store.data.histories[id].push({ role, content }); - const h = Store.data.histories[id]; - while (h.length > HISTORY_MAX_ITEMS) h.shift(); - const sizeOf = (x: { role: string; content: string }) => Buffer.byteLength(`${x.role}:${x.content}`); - let total = 0; - for (const x of h) total += sizeOf(x); - while (total > HISTORY_MAX_BYTES && h.length > 1) { const first = h.shift()!; total -= sizeOf(first); } - if (!Store.data.histMeta) Store.data.histMeta = {} as any; - (Store.data.histMeta as any)[id] = { lastAt: new Date().toISOString() }; - pruneGlobalHistories(); + +type ImageContext = { + providerConfig: ProviderConfig; + model: string; + config: DB; + modeConfig: ProviderModeConfig; + prompt: string; + image?: AIImage; + token?: AbortToken; }; -/* ---------- 文本清理 & 格式化 ---------- */ -const cleanTextBasic = (t: string): string => - t - .replace(/\uFEFF/g, "") - .replace(/[\uFFFC\uFFFF\uFFFE]/g, "") - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") - .replace(/\r\n/g, "\n") - .replace(/[\u200B\u200C\u200D\u2060]/g, "") - .normalize("NFKC"); -const escapeAndFormatForTelegram = (raw: string): string => { - const cleaned = cleanTextBasic(raw || ""); - let escaped = html(cleaned); - escaped = escaped.replace(/\*\*([^*]+)\*\*/g, "$1"); - escaped = escaped.replace(/\*\*\s*\[?引用来源]?\s*\*\*/g, "引用来源"); - escaped = escaped.replace(/^\s*-\s*\[([^]]+)]\((https?:\/\/[^\s)]+)\)\s*$/gm, (_m, title: string, url: string) => { - const href = html(String(url)); - return `• ${title}`; - }); - const urlRegex = /\bhttps?:\/\/[^\s<>"')}\x5D]+/g; - const urls = cleaned.match(urlRegex) || []; - for (const u of urls) { - const display = shortenUrlForDisplay(u); - const escapedUrl = html(u); - const anchor = `${html(display)}`; - escaped = escaped.replace(new RegExp(escapeRegExp(escapedUrl), "g"), anchor); - } - escaped = escaped.replace(/^>\s?(.+)$/gm, "
$1
"); - return escaped; +type VideoContext = { + providerConfig: ProviderConfig; + model: string; + config: DB; + modeConfig: ProviderModeConfig; + prompt: string; + images: AIContentPart[]; + imageMode: VideoImageMode; + token?: AbortToken; }; -/* ---------- 路由降级 ---------- */ -const isRouteError = (err: any): boolean => { - const s = err?.response?.status; - const txt = String(err?.response?.data || err?.message || "").toLowerCase(); - return s === 404 || s === 405 || (s === 400 && /(unknown|not found|invalid path|no route)/.test(txt)); +type StrategyHandler = { + chat?: (ctx: ChatContext) => Promise<{ text: string; images: AIImage[] }>; + search?: (ctx: ChatContext) => Promise<{ text: string; sources: Array<{ url: string; title?: string }> }>; + image?: (ctx: ImageContext) => Promise; + video?: (ctx: VideoContext) => Promise; }; -const geminiRequestWithFallback = async (p: Provider, path: string, axiosConfig: any): Promise => { - const base = trimBase(p.baseUrl); - const mkConfigs = () => { - const baseCfg = { ...axiosConfig }; - const headersBase = { ...(baseCfg.headers || {}) }; - const paramsBase = { ...(baseCfg.params || {}) }; - const cfgKey = { ...baseCfg, headers: { ...headersBase }, params: { ...paramsBase, key: p.apiKey } }; - const cfgXGoog = { ...baseCfg, headers: { ...headersBase, "x-goog-api-key": p.apiKey }, params: { ...paramsBase } }; - const cfgAuth = { ...baseCfg, headers: { ...headersBase, Authorization: `Bearer ${p.apiKey}` }, params: { ...paramsBase } }; - const pref = p.compatauth; - const ordered = (pref === "openai" || pref === "claude") ? [cfgAuth, cfgXGoog, cfgKey] : [cfgKey, cfgXGoog, cfgAuth]; - const seen = new Set(); - const out: any[] = []; - for (const c of ordered) { - const sig = JSON.stringify({ h: c.headers || {}, p: c.params || {} }); - if (!seen.has(sig)) { seen.add(sig); out.push(c); } - } - return out; - }; - const configs = mkConfigs(); - const paths = [`/v1beta${path}`, `/v1${path}`]; - let lastErr: any; - for (const suffix of paths) { - for (const cfg of configs) { - try { - const r = await axiosWithRetry({ url: base + suffix, ...cfg }); - return r.data; - } catch (err: any) { - lastErr = err; - if (isRouteError(err)) break; - } - } + +const hosts = ( + hostList: string[], + profile: ProviderProfile +): Record => { + const out: Record = {}; + for (const h of hostList) { + const host = h.trim(); + if (!host) continue; + out[host] = profile; } - throw lastErr; + return out; +}; + +const PROVIDER_PROFILES: Record = { + "generativelanguage.googleapis.com": { + id: "gemini", + authMode: "query-key", + modes: { + chat: { strategy: "gemini-rest" }, + search: { strategy: "gemini-rest" }, + image: { strategy: "gemini-rest" }, + video: { + strategy: "gemini-video-rest", + baseUrlType: "gemini", + endpoint: "v1beta/models/{model}:generateVideos", + }, + }, + }, + "ark.cn-beijing.volces.com": { + id: "doubao", + authMode: "bearer", + modes: { + chat: { + strategy: "openai-rest", + baseUrlType: "origin", + endpoint: "api/v3/chat/completions", + imageUrlPolicy: "data-only", + }, + image: { + strategy: "doubao-rest", + baseUrlType: "origin", + endpoint: "api/v3/images/generations", + imageDefaults: { + size: "2K", + responseFormat: "url", + extraParams: { + sequential_image_generation: "disabled", + watermark: true, + }, + }, + supportsEdit: true, + }, + video: { + strategy: "doubao-rest", + baseUrlType: "origin", + endpoint: "api/v3/contents/generations/tasks", + videoDefaults: { + extraParams: {}, + }, + }, + }, + }, + "api.openai.com": { + id: "openai", + authMode: "bearer", + modes: { + chat: { strategy: "openai-rest" }, + search: { strategy: "openai-rest" }, + image: { strategy: "openai-rest", supportsEdit: true }, + video: { strategy: "openai-rest", endpoint: "chat/completions" }, + }, + }, + "api.moonshot.cn": { + id: "moonshot", + authMode: "bearer", + modes: { + chat: { strategy: "openai-rest" }, + }, + }, + ...hosts( + ["127.0.0.1", "api.abjj.de"], + { + id: "local-cliproxy", + authMode: "query-key", + modes: { + chat: { strategy: "openai-rest", baseUrlType: "openai" }, + search: { + strategy: "openai-rest", + baseUrlType: "openai", + modelRules: [ + { + match: { type: "includes", value: "gemini" }, + override: { + strategy: "gemini-rest", + baseUrlType: "gemini" + } + } + ] + }, + image: { + strategy: "gemini-image-rest", + baseUrlType: "gemini", + endpoint: "models/{model}:generateContent", + authMode: "query-key", + supportsEdit: true, + }, + video: { strategy: "openai-rest", baseUrlType: "openai", endpoint: "chat/completions" }, + }, + }, + ), +}; + +const DEFAULT_PROVIDER_PROFILE: ProviderProfile = { + id: "openai-compatible", + authMode: "bearer", + modes: { + chat: { strategy: "openai-rest" }, + search: { strategy: "openai-rest" }, + image: { strategy: "openai-rest", supportsEdit: true }, + video: { strategy: "openai-rest", endpoint: "chat/completions" }, + }, }; -/* ---------- Anthropic 版本缓存 ---------- */ -const anthropicVersionCache = new Map(); -const getAnthropicVersion = async (p: Provider): Promise => { - const key = trimBase(p.baseUrl) || "anthropic"; - const cached = anthropicVersionCache.get(key); - if (cached) return cached; - let ver = "2023-06-01"; - const base = trimBase(p.baseUrl); +const getProviderHost = (url: string): string | null => { try { - await axiosWithRetry({ method: "GET", url: base + "/v1/models", headers: { "x-api-key": p.apiKey } }); - } catch (err: any) { - const txt = JSON.stringify(err?.response?.data || err?.message || ""); - const matches = txt.match(/\b20\d{2}-\d{2}-\d{2}\b/g); - if (matches && matches.length) { - matches.sort(); - ver = matches[matches.length - 1]; - } + return new URL(url).hostname; + } catch { + return null; } - anthropicVersionCache.set(key, ver); - return ver; }; -/* ---------- AI 响应清理工具 ---------- */ - -/** - * 清理 AI 思考标签(...) - * 一些模型会返回带有思考过程的响应,需要移除 - */ -const cleanAIThinking = (text: string): string => { - // 移除 ... 标签及其内容 - let cleaned = text.replace(/[\s\S]*?<\/think>/gi, ""); - // 同时处理 [think]...[/think] 格式 - cleaned = cleaned.replace(/\[think\][\s\S]*?\[\/think\]/gi, ""); - return cleaned.trim(); +const getProviderProfile = (url: string): ProviderProfile => { + const host = getProviderHost(url); + if (!host) return DEFAULT_PROVIDER_PROFILE; + return PROVIDER_PROFILES[host] ?? DEFAULT_PROVIDER_PROFILE; }; -/** - * 从文本中提取内嵌的 base64 图片 - * 支持 Markdown 图片格式:![alt](data:image/...;base64,...) - * 以及直接的 data URI 格式 - * @returns 提取的图片数组,包含 base64 数据和 mime 类型 - */ -const extractEmbeddedImages = (text: string): Array<{ data: string; mime: string; alt?: string }> => { - const images: Array<{ data: string; mime: string; alt?: string }> = []; +const mergeDefaults = }>(a?: T, b?: T): T | undefined => { + if (!a && !b) return undefined; + return { + ...(a || {}), + ...(b || {}), + extraParams: { ...(a?.extraParams || {}), ...(b?.extraParams || {}) }, + } as T; +}; - // 匹配 Markdown 格式: ![alt](data:image/xxx;base64,...) - const mdRegex = /!\[([^\]]*)\]\((data:image\/([a-z0-9+.-]+);base64,([A-Za-z0-9+/=]+))\)/gi; - let match: RegExpExecArray | null; - while ((match = mdRegex.exec(text)) !== null) { - const alt = match[1]; - const mimeType = match[3]; // jpeg, png, webp, etc. - const base64Data = match[4]; - if (base64Data && base64Data.length > 100) { // 确保是有效的图片数据 - images.push({ data: base64Data, mime: `image/${mimeType}`, alt }); +const matchModelRule = (model: string, rule: ModelMatchRule): boolean => { + if (!model) return false; + if (rule.type === "exact") return model === rule.value; + if (rule.type === "prefix") return model.startsWith(rule.value); + if (rule.type === "includes") return model.includes(rule.value); + if (rule.type === "regex") { + try { + return new RegExp(rule.value).test(model); + } catch { + return false; } } + return false; +}; + +const resolveModeConfig = ( + profile: ProviderProfile, + mode: ProviderMode, + model: string +): ProviderModeConfig | undefined => { + const base = profile.modes[mode]; + if (!base) return undefined; + const rules = base.modelRules || []; + const matchedRule = rules.find((rule) => matchModelRule(model, rule.match)); + if (!matchedRule) return { ...base }; + const ruleOverrides = matchedRule.override || {}; + return { + ...base, + ...ruleOverrides, + imageDefaults: mergeDefaults(base.imageDefaults, ruleOverrides.imageDefaults), + videoDefaults: mergeDefaults(base.videoDefaults, ruleOverrides.videoDefaults), + }; +}; - // 匹配直接的 data URI 格式(不在 Markdown 中) - // 避免重复匹配已经在 Markdown 中处理过的 - const dataUriRegex = /(? img.data.substring(0, 100) === base64Data.substring(0, 100)); - if (!isDuplicate && base64Data.length > 100) { - images.push({ data: base64Data, mime: `image/${mimeType}` }); - } +const resolveBaseUrl = ( + providerConfig: ProviderConfig, + modeConfig: ProviderModeConfig +): string => { + const baseType = modeConfig.baseUrlType ?? "raw"; + if (baseType === "origin") { + return new URL(providerConfig.url).origin; + } + if (baseType === "openai") { + return normalizeOpenAIBaseUrl(providerConfig.url); } + if (baseType === "gemini") { + return normalizeGeminiBaseUrl(providerConfig.url); + } + return providerConfig.url; +}; - return images; +const resolveEndpointUrl = (baseUrl: string, endpoint?: string): string => { + if (!endpoint) return baseUrl; + if (/^https?:\/\//.test(endpoint)) return endpoint; + const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const cleaned = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; + return new URL(cleaned, base).toString(); }; -/** - * 从文本中移除内嵌图片,只保留文字内容 - */ -const cleanEmbeddedImages = (text: string): string => { - // 移除 Markdown 图片格式 - let cleaned = text.replace(/!\[[^\]]*\]\(data:image\/[a-z0-9+.-]+;base64,[A-Za-z0-9+/=]+\)/gi, "[图片]"); - // 移除直接的 data URI - cleaned = cleaned.replace(/data:image\/[a-z0-9+.-]+;base64,[A-Za-z0-9+/=]{100,}/gi, "[图片数据]"); - return cleaned.trim(); +const getMessageText = (m?: Api.Message | null): string => { + if (!m) return ""; + const text = (m as any).message ?? (m as any).text ?? ""; + return typeof text === "string" ? text : ""; }; -/** - * 处理 AI 响应,提取图片和清理文本 - * @returns 处理后的结果,包含清理后的文本和提取的图片 - */ -const processAIResponse = (rawContent: string): { text: string; images: Array<{ data: string; mime: string; alt?: string }> } => { - // 首先清理思考标签 - const withoutThinking = cleanAIThinking(rawContent); +const htmlEscape = (text: string): string => + text.replace(/&/g, "&").replace(//g, ">"); - // 提取内嵌图片 - const images = extractEmbeddedImages(withoutThinking); +const buildUserContent = (text: string, images: AIContentPart[]): string | AIContentPart[] => { + if (images.length === 0) return text; + const parts: AIContentPart[] = []; + if (text.trim()) parts.push({ type: "text", text }); + parts.push(...images); + return parts; +}; - // 清理图片数据,只保留文字 - let text = withoutThinking; - if (images.length > 0) { - text = cleanEmbeddedImages(withoutThinking); +const extractErrorMessage = (error: any): string => { + const msgText = typeof error?.message === "string" ? error.message : ""; + const reasonText = + typeof error?.cause === "string" + ? error.cause + : error?.cause + ? String(error.cause) + : error?.config?.signal?.reason + ? String(error.config.signal.reason) + : ""; + + if ((msgText + reasonText).includes("请求超时")) return "请求超时"; + if (error?.name === "AbortError" || msgText.toLowerCase().includes("aborted")) return "操作已取消"; + if (error?.code === "ECONNABORTED") return "请求超时"; + if (error?.response?.status === 429) return "请求过于频繁,请稍后重试"; + return error?.response?.data?.error?.message || error?.response?.data?.message || msgText || "未知错误"; +}; + +class UserError extends Error { + constructor(message: string) { + super(message); + this.name = "UserError"; } +} - return { text, images }; +const requireUser = (condition: any, message: string): void => { + if (!condition) throw new UserError(message); }; -/* ---------- 格式化 Q&A ---------- */ -const formatQA = (qRaw: string, aRaw: string) => { - const expandAttr = Store.data.collapse ? ' expandable' : ""; - const qEsc = escapeAndFormatForTelegram(qRaw); - const aEsc = escapeAndFormatForTelegram(aRaw); - const Q = `Q:\n${qEsc}`; - const A = `A:\n${aEsc}`; - return `${Q}\n\n${A}`; -}; +type ProcessingKind = "chat" | "search" | "image" | "video"; -/* ---------- Telegraph 工具 ---------- */ -const toNodes = (text: string) => JSON.stringify(text.split("\n\n").map(p => ({ tag: "p", children: [p] }))); -const ensureTGToken = async (): Promise => { - if (Store.data.telegraph.token) return Store.data.telegraph.token; - const resp = await axiosWithRetry({ - method: "POST", - url: "https://api.telegra.ph/createAccount", - params: { short_name: "TeleBoxAI", author_name: "TeleBox" } - }); - const t = resp.data?.result?.access_token || ""; - Store.data.telegraph.token = t; - await Store.writeSoon(); - return t; +const PROCESSING_TEXT: Record = { + chat: "💬 正在处理chat任务", + search: "🔎 正在处理search任务", + image: "🖼️ 正在处理image任务", + video: "🎬 正在处理video任务", }; -const createTGPage = async (title: string, text: string): Promise => { - // Telegraph 单页限制约 64KB JSON,8000安全值 - const MAX_CHARS_PER_PAGE = 8000; - - const tryCreate = async (token: string, pageTitle: string, content: string): Promise => { - const contentNodes = JSON.parse(toNodes(content)); - const resp = await axiosWithRetry({ - method: "POST", - url: "https://api.telegra.ph/createPage", - headers: { "Content-Type": "application/json" }, - data: { - access_token: token, - title: pageTitle, - content: contentNodes, - return_content: false - } - }); - if (!resp.data?.ok) { - return null; - } - return resp.data?.result?.url || null; - }; - - // 按段落分割内容成多个块 - const splitContent = (fullText: string): string[] => { - if (fullText.length <= MAX_CHARS_PER_PAGE) return [fullText]; - - const paragraphs = fullText.split("\n\n"); - const chunks: string[] = []; - let current = ""; - - for (const para of paragraphs) { - const next = current ? current + "\n\n" + para : para; - if (next.length > MAX_CHARS_PER_PAGE && current) { - chunks.push(current); - current = para; - } else { - current = next; - } - } - if (current) chunks.push(current); - return chunks; - }; - - try { - const token = await ensureTGToken(); - if (!token) return []; - - const chunks = splitContent(text); - - - // 多页创建 - const urls: string[] = []; - for (let i = 0; i < chunks.length; i++) { - const pageTitle = title; - try { - const url = await tryCreate(token, pageTitle, chunks[i]); - if (url) urls.push(url); - } catch { - // 页面创建失败,静默处理 - } - } - return urls; - } catch { - return []; +const formatErrorForDisplay = (error: any): string => { + if ( + error instanceof UserError || + error?.name === "AbortError" || + (typeof error?.message === "string" && error.message.toLowerCase().includes("aborted")) + ) { + const extracted = extractErrorMessage(error); + if (extracted === "请求超时") return `❌ 错误: 请求超时`; + const msg = error instanceof UserError ? error.message : "操作已取消"; + return `🚫 ${msg}`; } + return `❌ 错误: ${extractErrorMessage(error)}`; }; +const sendProcessing = async (msg: Api.Message, kind: ProcessingKind): Promise => { + await MessageSender.sendOrEdit(msg, PROCESSING_TEXT[kind], { parseMode: "html" }); +}; + +const sendErrorMessage = async (msg: Api.Message, error: any, trigger?: Api.Message): Promise => { + await MessageSender.sendOrEdit(trigger || msg, formatErrorForDisplay(error), { parseMode: "html" }); +}; -/* ---------- 媒体处理辅助函数 ---------- */ +const parseDataUrl = (url: string): { mimeType: string; data: Buffer } | null => { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!match) return null; + return { mimeType: match[1], data: Buffer.from(match[2], "base64") }; +}; -// 归一化下载的媒体结果 const normalizeDownloadedMedia = async (downloaded: any): Promise => { if (!downloaded) return null; if (Buffer.isBuffer(downloaded)) return downloaded; @@ -845,42 +492,81 @@ const normalizeDownloadedMedia = async (downloaded: any): Promise return null; }; -// 提取第一帧 (GIF/WebM/Sticker) +const getImageExtensionForMime = (mimeType: string): string => { + if (mimeType === "image/png") return ".png"; + if (mimeType === "image/webp") return ".webp"; + if (mimeType === "image/gif") return ".gif"; + return ".jpg"; +}; + const extractFirstFrame = async (buffer: Buffer): Promise => { try { - // animated: true 读取第一帧,转为 png return await sharp(buffer, { animated: true }).png().toBuffer(); } catch { return null; } }; -// 获取 Document 缩略图 const getDocumentThumb = (doc: Api.Document): Api.TypePhotoSize | undefined => { const thumbs = doc.thumbs || []; if (thumbs.length === 0) return undefined; return thumbs[thumbs.length - 1]; }; -/** - * 智能下载并处理消息中的媒体(支持图片、GIF、贴纸等) - * 返回适合 AI 视觉模型的 Buffer (通常是 PNG/JPEG) - */ -const downloadMessageMediaAsData = async (msg: Api.Message): Promise<{ buffer: Buffer; mime: string } | null> => { - if (!msg?.media || !msg.client) return null; +const resolveImageInputs = async ( + parts: AIContentPart[], + httpClient: HttpClient, + token?: AbortToken, + options?: { allowFailures?: boolean } +): Promise => { + const resolved: ResolvedImageData[] = []; + const allowFailures = options?.allowFailures ?? false; + for (const part of parts) { + if (part.type !== "image_url") continue; + const dataUrl = parseDataUrl(part.image_url.url); + if (dataUrl) { + resolved.push({ data: dataUrl.data, mimeType: dataUrl.mimeType }); + if (!allowFailures) break; + continue; + } + try { + const image = await resolveAIImageData({ url: part.image_url.url, mimeType: "image/jpeg" }, httpClient, token); + if (image?.data) { + resolved.push({ data: image.data, mimeType: image.mimeType }); + if (!allowFailures) break; + } + } catch (error) { + if (!allowFailures) throw error; + } + } + return resolved; +}; + +const resolveImagePart = async ( + parts: AIContentPart[], + httpClient: HttpClient, + token?: AbortToken +): Promise => { + const resolved = await resolveImageInputs(parts, httpClient, token, { allowFailures: false }); + if (!resolved.length) return null; + return { data: resolved[0].data, mimeType: resolved[0].mimeType }; +}; + +const collectImagePartsFromSingleMessage = async (msg: Api.Message, out: AIContentPart[]): Promise => { + if (!msg.media || !msg.client) return; - // 1. 普通 Photo if (msg.media instanceof Api.MessageMediaPhoto) { const downloaded = await msg.client.downloadMedia(msg); const buffer = await normalizeDownloadedMedia(downloaded); - if (!buffer) return null; - return { buffer, mime: "image/jpeg" }; + if (!buffer) return; + const dataUrl = `data:image/jpeg;base64,${buffer.toString("base64")}`; + out.push({ type: "image_url", image_url: { url: dataUrl } }); + return; } - // 2. Document (可能是普通图片、GIF、贴纸) if (msg.media instanceof Api.MessageMediaDocument && msg.media.document instanceof Api.Document) { const doc = msg.media.document; - const docMime = (doc.mimeType || "").toLowerCase(); + const docMime = doc.mimeType || ""; const isAnimated = docMime === "image/gif" || docMime === "video/webm" || @@ -888,1806 +574,3193 @@ const downloadMessageMediaAsData = async (msg: Api.Message): Promise<{ buffer: B docMime === "application/x-tg-sticker" || doc.attributes?.some((attr) => attr instanceof Api.DocumentAttributeAnimated); - // 2.1 静态图片 Document + const thumb = getDocumentThumb(doc); + if (!isAnimated && docMime.startsWith("image/")) { const downloaded = await msg.client.downloadMedia(msg); const buffer = await normalizeDownloadedMedia(downloaded); - if (!buffer) return null; - return { buffer, mime: docMime }; + if (!buffer) return; + const dataUrl = `data:${docMime};base64,${buffer.toString("base64")}`; + out.push({ type: "image_url", image_url: { url: dataUrl } }); + return; } - // 2.2 动态媒体 (GIF / WebM / Sticker) -> 抽帧 let frameBuffer: Buffer | null = null; - // 优先尝试利用 Telegram 提供的缩略图 - const thumb = getDocumentThumb(doc); if (thumb) { - try { - const downloaded = await msg.client.downloadMedia(msg, { thumb }); - const buffer = await normalizeDownloadedMedia(downloaded); - if (buffer) { - // 确保是 PNG - try { frameBuffer = await sharp(buffer).png().toBuffer(); } catch { frameBuffer = buffer; } + const downloaded = await msg.client.downloadMedia(msg, { thumb }); + const buffer = await normalizeDownloadedMedia(downloaded); + if (buffer) { + try { + frameBuffer = await sharp(buffer).png().toBuffer(); + } catch { + frameBuffer = buffer; } - } catch { - // 缩略图下载失败,回退到原文件 } } - // 如果没有缩略图或失败,尝试下载原文件并抽帧 if (!frameBuffer) { - try { - const downloaded = await msg.client.downloadMedia(msg); - const buffer = await normalizeDownloadedMedia(downloaded); - if (buffer) { + const downloaded = await msg.client.downloadMedia(msg); + const buffer = await normalizeDownloadedMedia(downloaded); + if (buffer) { + try { frameBuffer = await extractFirstFrame(buffer); + } catch { + frameBuffer = null; } - } catch { - // 原文件下载/抽帧失败 } } - if (frameBuffer) { - return { buffer: frameBuffer, mime: "image/png" }; - } - } + if (!frameBuffer) return; - return null; + const dataUrl = `data:image/png;base64,${frameBuffer.toString("base64")}`; + out.push({ type: "image_url", image_url: { url: dataUrl } }); + } }; -/* ---------- 聊天适配 ---------- */ -const chatOpenAI = async (p: Provider, model: string, msgs: { role: string; content: string }[], maxTokens?: number, useSearch?: boolean) => { - const url = trimBase(p.baseUrl) + "/v1/chat/completions"; - const effectiveMaxTokens = maxTokens || Store.data.maxTokens || DEFAULT_MAX_TOKENS; - const body: any = { model, messages: msgs, max_tokens: effectiveMaxTokens }; - if (useSearch && p.baseUrl?.includes("api.openai.com")) { - body.tools = [{ - type: "function", - function: { - name: "web_search", - description: "Search the web for current information and return relevant results", - parameters: { - type: "object", - properties: { query: { type: "string", description: "The search query to execute" } }, - required: ["query"] - } - } - }]; - } else if (useSearch) { - const searchPrompt = "请基于你的知识回答以下问题,如果需要最新信息请说明。"; - msgs[msgs.length - 1].content = searchPrompt + "\n\n" + msgs[msgs.length - 1].content; +const getMessageImageParts = async (msg?: Api.Message): Promise => { + if (!msg?.client) return []; + + const parts: AIContentPart[] = []; + + const rawGroupedId = (msg as any).groupedId; + const groupedId = rawGroupedId ? rawGroupedId.toString() : undefined; + + if (!groupedId) { + await collectImagePartsFromSingleMessage(msg, parts); + return parts; } - const attempts = buildAuthAttempts(p); - try { - const data: any = await tryPostJSON(url, body, attempts); - return data?.choices?.[0]?.message?.content || ""; - } catch (lastErr: any) { - const status = lastErr?.response?.status; - const bodyErr = lastErr?.response?.data; - const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); - throw new Error(`[chatOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); + + const peer = msg.chatId || msg.peerId; + const sameGroupMessages: Api.Message[] = []; + + for await (const m of msg.client.iterMessages(peer, { limit: 50 })) { + if (!(m instanceof Api.Message)) continue; + + const g = (m as any).groupedId; + if (!g) continue; + + if (g.toString() !== groupedId) continue; + + sameGroupMessages.push(m); } -}; -const chatClaude = async (p: Provider, model: string, msgs: { role: string; content: string }[], maxTokens?: number, useSearch?: boolean) => { - const url = trimBase(p.baseUrl) + "/v1/messages"; - const effectiveMaxTokens = maxTokens || Store.data.maxTokens || DEFAULT_MAX_TOKENS; - const body: any = { model, max_tokens: effectiveMaxTokens, messages: msgs.map(m => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })) }; - if (useSearch && p.baseUrl?.includes("api.anthropic.com")) { - body.tools = [{ type: "web_search_20241220", name: "web_search", max_uses: 3 }]; - } - const v = await getAnthropicVersion(p); - const attempts = buildAuthAttempts(p, { "anthropic-version": v }); - try { - const data: any = await tryPostJSON(url, body, attempts); - if (data?.content && Array.isArray(data.content)) { - const textBlocks = data.content - .filter((block: any) => block.type === "text") - .map((block: any) => block.text) - .filter((text: string) => text && text.trim()); - if (textBlocks.length > 0) return textBlocks.join("\n\n"); - } - const possibleTexts = [ - data?.content?.[0]?.text, - data?.message?.content?.[0]?.text, - data?.choices?.[0]?.message?.content, - data?.response, - data?.text, - data?.content, - data?.message?.content, - data?.output - ]; - for (const text of possibleTexts) if (typeof text === "string" && text.trim()) return text.trim(); - return ""; - } catch (lastErr: any) { - const status = lastErr?.response?.status; - const bodyErr = lastErr?.response?.data; - const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); - throw new Error(`[chatClaude] adapter=claude model=${html(model)} status=${status || "network"} message=${msg}`); + + sameGroupMessages.sort((a, b) => Number(a.id) - Number(b.id)); + + for (const m of sameGroupMessages) { + await collectImagePartsFromSingleMessage(m, parts); } -}; -const chatGemini = async (p: Provider, model: string, msgs: { role: string; content: string }[], useSearch: boolean = false) => { - const path = `/models/${encodeURIComponent(model)}:generateContent`; - const effectiveMaxTokens = Store.data.maxTokens || DEFAULT_MAX_TOKENS; - const requestData: any = { - contents: [{ parts: msgs.map(m => ({ text: m.content })) }], - generationConfig: { - maxOutputTokens: effectiveMaxTokens, - temperature: 0.9 - } - }; - if (useSearch) requestData.tools = [{ googleSearch: {} }]; - const data = await geminiRequestWithFallback(p, path, { - method: "POST", - data: requestData, - params: { key: p.apiKey } - }); - const parts = data?.candidates?.[0]?.content?.parts || []; - return parts.map((x: any) => x.text || "").join(""); + + return parts; }; -/* ---------- 视觉对话 ---------- */ -const chatVisionOpenAI = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { - const url = trimBase(p.baseUrl) + "/v1/chat/completions"; - const content = [ - { type: "text", text: prompt || "用中文描述此图片" }, - { type: "image_url", image_url: { url: `data:${mime || "image/png"};base64,${imageB64}` } } - ]; - const body = { model, messages: [{ role: "user", content }] }; - const attempts = buildAuthAttempts(p); - try { - const data: any = await tryPostJSON(url, body, attempts); - return data?.choices?.[0]?.message?.content || ""; - } catch (lastErr: any) { - const status = lastErr?.response?.status; - const bodyErr = lastErr?.response?.data; - const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); - throw new Error(`[chatVisionOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); +const getGroupedMessageIds = async (msg: Api.Message): Promise => { + if (!msg?.client) return []; + const rawGroupedId = (msg as any).groupedId; + const groupedId = rawGroupedId ? rawGroupedId.toString() : undefined; + if (!groupedId) return []; + + const peer = msg.chatId || msg.peerId; + const ids: number[] = []; + + for await (const m of msg.client.iterMessages(peer, { limit: 50 })) { + if (!(m instanceof Api.Message)) continue; + const g = (m as any).groupedId; + if (!g) continue; + if (g.toString() !== groupedId) continue; + ids.push(Number(m.id)); } + + if (!ids.includes(Number(msg.id))) ids.push(Number(msg.id)); + + return Array.from(new Set(ids)).sort((a, b) => a - b); }; -const chatVisionGemini = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { - const path = `/models/${encodeURIComponent(model)}:generateContent`; + +const deleteMessageOrGroup = async (msg: Api.Message): Promise => { try { - const data = await geminiRequestWithFallback(p, path, { - method: "POST", - data: { - contents: [ - { - role: "user", - parts: [ - { inlineData: { mimeType: mime || "image/png", data: imageB64 } }, - { text: prompt || "用中文描述此图片" } - ] - } - ] - }, - params: { key: p.apiKey } - }); - const parts = data?.candidates?.[0]?.content?.parts || []; - return parts.map((x: any) => x.text || "").join(""); - } catch (err: any) { - const status = err?.response?.status; - const body = err?.response?.data; - const msg = body?.error?.message || body?.message || err?.message || String(err); - throw new Error(`[chatVisionGemini] adapter=gemini model=${html(model)} status=${status || "network"} message=${msg}`); - } + if (!msg?.client) return; + const peer = msg.chatId || msg.peerId; + const ids = await getGroupedMessageIds(msg); + + if (ids.length > 1) { + await msg.client.deleteMessages(peer, ids, { revoke: true }); + return; + } + await msg.delete(); + } catch { } }; -const chatVisionClaude = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { - const url = trimBase(p.baseUrl) + "/v1/messages"; - const v = await getAnthropicVersion(p); - const body = { - model, - max_tokens: 1024, - messages: [ - { - role: "user", - content: [ - { type: "text", text: prompt || "用中文描述此图片" }, - { type: "image", source: { type: "base64", media_type: mime || "image/png", data: imageB64 } } - ] - } - ] - }; - const attempts = buildAuthAttempts(p, { "anthropic-version": v }); - try { - const data: any = await tryPostJSON(url, body, attempts); - const blocks = data?.content || data?.message?.content || []; - return Array.isArray(blocks) ? blocks.map((b: any) => b?.text || b?.content?.[0]?.text || "").join("") : ""; - } catch (lastErr: any) { - const status = lastErr?.response?.status; - const bodyErr = lastErr?.response?.data; - const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); - throw new Error(`[chatVisionClaude] adapter=claude model=${html(model)} status=${status || "network"} message=${msg}`); - } + +const resolveAIImageData = async ( + image: AIImage, + httpClient: HttpClient, + token?: AbortToken +): Promise => { + if (image.data) return image; + if (!image.url) return null; + const response = await httpClient.request( + { + url: image.url, + method: "GET", + responseType: "arraybuffer", + }, + token + ); + const contentType = response.headers?.["content-type"]?.split(";")[0] || image.mimeType || "image/jpeg"; + return { data: Buffer.from(response.data), mimeType: contentType }; }; -const chatVision = async (p: Provider, compat: string, model: string, imageB64: string, prompt?: string, mime?: string): Promise => { - if (compat === "openai") return chatVisionOpenAI(p, model, imageB64, prompt, mime); - if (compat === "gemini") return chatVisionGemini(p, model, imageB64, prompt, mime); - if (compat === "claude") return chatVisionClaude(p, model, imageB64, prompt, mime); - return chatOpenAI(p, model, [{ role: "user", content: prompt || "描述这张图片" } as any] as any); + +const getVideoExtensionForMime = (mimeType: string): string => { + if (mimeType === "video/webm") return ".webm"; + if (mimeType === "video/quicktime") return ".mov"; + return ".mp4"; }; -/* ---------- 生图 ---------- */ -const imageOpenAI = async ( - p: Provider, - model: string, - prompt: string, - sourceImage?: { data: string; mime: string } -): Promise => { - const base = trimBase(p.baseUrl); - const isEdit = !!sourceImage; - - // 尝试多种方式:优先使用 generations 端点(大多数第三方兼容) - // 如果有源图片,将图片 base64 嵌入请求体(某些平台如豆包支持此方式) - const url = base + "/v1/images/generations"; - - // 根据模型选择合适的分辨率 - // 某些模型(如豆包 imagen)要求最小 3686400 像素 (1920x1920) - // 通用模型使用 1024x1024 - const modelLower = model.toLowerCase(); - const needsHighRes = modelLower.includes("imagen") || - modelLower.includes("sd3") || - modelLower.includes("sdxl") || - modelLower.includes("flux") || - modelLower.includes("seedream") || - modelLower.includes("doubao"); - const imageSize = needsHighRes ? "2048x2048" : "1024x1024"; - - // 构建请求体 - let body: any = { - model, - prompt, - n: 1, - response_format: "b64_json", - size: imageSize - }; +const resolveAIVideoData = async ( + video: AIVideo, + httpClient: HttpClient, + token?: AbortToken +): Promise => { + if (video.data) return video; + if (!video.url) return null; + const response = await httpClient.request( + { + url: video.url, + method: "GET", + responseType: "arraybuffer", + }, + token + ); + const contentType = response.headers?.["content-type"]?.split(";")[0] || video.mimeType || "video/mp4"; + return { data: Buffer.from(response.data), mimeType: contentType }; +}; - // 如果有源图片,添加到请求体(兼容某些支持图生图的第三方平台) - if (isEdit && sourceImage) { - body.image = `data:${sourceImage.mime};base64,${sourceImage.data}`; +const videoHasAudioTrack = async (filePath: string): Promise => { + try { + const { stdout } = await execFileAsync("ffprobe", [ + "-v", + "error", + "-show_streams", + "-select_streams", + "a:0", + "-of", + "json", + filePath, + ]); + + const info = JSON.parse(stdout); + const streams = info.streams || []; + return streams.length > 0; + } catch { + return false; } +}; - const attempts = buildAuthAttempts(p, { "Content-Type": "application/json" }); - +const ensureVideoHasAudio = async (inputPath: string, outputPath: string): Promise => { try { - const data = await tryPostJSON(url, body, attempts); - const first = data?.data?.[0] || {}; - const b64 = first?.b64_json || first?.image_base64 || first?.image || ""; - if (b64) return String(b64); - const urlOut = first?.url || first?.image_url; - if (urlOut) { - try { - const r = await axiosWithRetry({ method: "GET", url: String(urlOut), responseType: "arraybuffer" }); - const buf: any = r.data; - const b: Buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); - if (b && b.length > 0) return b.toString("base64"); - } catch { } - } - return ""; - } catch (err: any) { - // 如果 generations 端点不支持图生图,尝试 edits 端点 (标准 OpenAI 格式) - if (isEdit && sourceImage) { - try { - const editUrl = base + "/v1/images/edits"; - const editBody = { - model, - prompt, - image: `data:${sourceImage.mime};base64,${sourceImage.data}`, - n: 1, - response_format: "b64_json", - size: imageSize - }; - const editData = await tryPostJSON(editUrl, editBody, attempts); - const first = editData?.data?.[0] || {}; - const b64 = first?.b64_json || first?.image_base64 || first?.image || ""; - if (b64) return String(b64); - const urlOut = first?.url || first?.image_url; - if (urlOut) { - const r = await axiosWithRetry({ method: "GET", url: String(urlOut), responseType: "arraybuffer" }); - const buf: any = r.data; - const b: Buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); - if (b && b.length > 0) return b.toString("base64"); - } - } catch { } + const hasAudio = await videoHasAudioTrack(inputPath); + if (hasAudio) { + return inputPath; } - throw err; + + await execFileAsync("ffmpeg", [ + "-y", + "-i", + inputPath, + "-f", + "lavfi", + "-i", + "anullsrc=channel_layout=stereo:sample_rate=44100", + "-c:v", + "copy", + "-shortest", + "-c:a", + "aac", + "-b:a", + "128k", + outputPath, + ]); + + return outputPath; + } catch { + return inputPath; } }; -const imageGemini = async (p: Provider, model: string, prompt: string, sourceImage?: { data: string; mime: string }): Promise<{ image?: Buffer; text?: string; mime?: string }> => { - let imageModel = model; - if (!model.includes("image") && !model.includes("2.5-flash") && !model.includes("2.0-flash") && !model.includes("3-pro")) { - imageModel = "gemini-2.5-flash-image-preview"; - } - const path = `/models/${encodeURIComponent(imageModel)}:generateContent`; - // 构建请求内容 - 支持图生图 - const parts: any[] = []; - if (sourceImage) { - // 图生图:先添加原图,再添加提示词 - parts.push({ - inlineData: { - mimeType: sourceImage.mime, - data: sourceImage.data - } - }); +const createAbortToken = (): AbortToken => { + const controller = new AbortController(); + return { + get aborted() { + return controller.signal.aborted; + }, + get reason() { + return controller.signal.reason?.toString(); + }, + get signal() { + return controller.signal; + }, + abort(reason?: string) { + if (!controller.signal.aborted) controller.abort(reason); + }, + throwIfAborted() { + if (controller.signal.aborted) { + throw new UserError(controller.signal.reason?.toString() || "操作已取消"); + } + }, + }; +}; + +const sleep = (ms: number, token?: AbortToken): Promise => { + return new Promise((resolve, reject) => { + token?.throwIfAborted(); + let settled = false; + const cleanup = () => { + if (!token?.signal) return; + token.signal.removeEventListener("abort", abortHandler); + }; + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }, ms); + const abortHandler = () => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + cleanup(); + reject(new UserError(token?.reason?.toString() || "操作已取消")); + }; + if (token?.signal) token.signal.addEventListener("abort", abortHandler, { once: true }); + }); +}; + +const retryWithFixedDelay = async ( + operation: () => Promise, + maxRetries: number = 2, + delayMs: number = 1000, + token?: AbortToken +): Promise => { + let lastError: any; + for (let i = 0; i < maxRetries; i++) { + token?.throwIfAborted(); + try { + return await operation(); + } catch (error: any) { + lastError = error; + if (token?.aborted) throw error; + if (!isRetryableError(error)) throw error; + if (i === maxRetries - 1) break; + await sleep(delayMs, token); + } } - parts.push({ text: prompt }); + throw lastError; +}; - try { - const data = await geminiRequestWithFallback(p, path, { - method: "POST", - data: { - contents: [{ role: "user", parts }], - generationConfig: { responseModalities: ["TEXT", "IMAGE"], temperature: 0.7, maxOutputTokens: 2048 } +const isRetryableError = (error: any): boolean => { + if (!error) return false; + if (error.name === "AbortError") return false; + if (typeof error.message === "string" && error.message.toLowerCase().includes("aborted")) return false; + + const status = error.response?.status; + if (typeof status === "number") { + if (status === 429) return true; + if (status >= 500 && status <= 599) return true; + return false; + } + + if (error.isAxiosError && !error.response) return true; + if (typeof error.code === "string") return true; + + return false; +}; + +type TaskStatus = "pending" | "running" | "succeeded" | "failed"; + +interface TaskPollResult { + status: TaskStatus; + result?: T; + errorMessage?: string; +} + +interface TaskPollOptions { + maxAttempts?: number; + intervalMs?: number; +} + +type TaskFetchFn = (token?: AbortToken) => Promise; +type TaskParseFn = (data: any) => TaskPollResult; + +const pollTask = async ( + fetchJob: TaskFetchFn, + parseResult: TaskParseFn, + options: TaskPollOptions = {}, + token?: AbortToken +): Promise => { + const maxAttempts = options.maxAttempts ?? 303; + const intervalMs = options.intervalMs ?? 2000; + + for (let i = 0; i < maxAttempts; i++) { + token?.throwIfAborted(); + + const data = await retryWithFixedDelay(() => fetchJob(token), 2, 1000, token); + const result = parseResult(data); + + if (result.status === "failed") { + throw new Error(result.errorMessage || "任务执行失败"); + } + + if (result.status === "succeeded") { + if (result.result === undefined) { + throw new Error("任务成功但未返回结果"); + } + return result.result; + } + + await sleep(intervalMs, token); + } + + throw new Error("任务执行超时"); +}; + +interface MessageOptions { + parseMode?: string; + linkPreview?: boolean; +} + +class MessageSender { + static async sendOrEdit(msg: Api.Message, text: string, options?: MessageOptions): Promise { + try { + const edited = await msg.edit({ text, ...options }); + if (edited) return edited; + } catch (error: any) { + const msgText = typeof error?.message === "string" ? error.message : ""; + if (msgText.includes("MESSAGE_ID_INVALID") || msgText.includes("400")) { + const replied = await msg.reply({ message: text, ...options }); + if (replied) return replied; + } + throw error; + } + + const replied = await msg.reply({ message: text, ...options }); + if (replied) return replied; + throw new Error("消息发送失败"); + } + + static async sendNew( + msg: Api.Message, + text: string, + options?: MessageOptions, + replyToId?: number + ): Promise { + if (!msg.client) { + throw new Error("客户端未初始化"); + } + + return await msg.client.sendMessage(msg.chatId || msg.peerId, { + message: text, + ...(options || {}), + ...(replyToId ? { replyTo: replyToId } : {}), + }); + } +} + +class MessageUtils { + private configManagerPromise: Promise; + private httpClient: HttpClient; + private telegraphTokenPromise: Promise | null = null; + + constructor(configManagerPromise: Promise, httpClient: HttpClient) { + this.configManagerPromise = configManagerPromise; + this.httpClient = httpClient; + } + + async createTelegraphPage(markdown: string, titleSource?: string, token?: AbortToken): Promise { + const configManager = await this.configManagerPromise; + const config = configManager.getConfig(); + + const tgToken = await this.ensureTGToken(config, token); + const rawTitle = (titleSource || "").replace(/\s+/g, " ").trim(); + const shortTitle = rawTitle.length > 24 ? `${rawTitle.slice(0, 24)}…` : rawTitle; + const title = shortTitle || `Telegraph - ${new Date().toLocaleString()}`; + const nodes = TelegraphFormatter.toNodes(markdown); + + const response = await this.httpClient.request( + { + url: "https://api.telegra.ph/createPage", + method: "POST", + data: { + access_token: tgToken, + title, + content: nodes, + return_content: false, + }, }, - params: { key: p.apiKey } + token + ); + + const url = response.data?.result?.url; + if (!url) throw new Error(response.data?.error || "Telegraph页面创建失败"); + + return { url, title, createdAt: new Date().toISOString() }; + } + + async sendLongMessage( + msg: Api.Message, + text: string, + replyToId?: number, + token?: AbortToken, + options?: { poweredByTag?: string } + ): Promise { + token?.throwIfAborted(); + + const configManager = await this.configManagerPromise; + const config = configManager.getConfig(); + + const poweredByTag = (options?.poweredByTag ?? config.currentChatTag) || ""; + const poweredByText = poweredByTag ? `\n🍀Powered by ${poweredByTag}` : ""; + + if (text.length <= 4050) { + token?.throwIfAborted(); + + const parts = text.split(/(?=A:\n)/); + if (parts.length === 2) { + const questionPart = parts[0]; + const answerPart = parts[1]; + const cleanAnswer = answerPart.replace(/^A:\n/, ""); + const cleanQuestion = questionPart.replace(/^Q:\n/, "").replace(/\n\n$/, ""); + const questionBlock = `Q:\n${this.wrapHtmlWithCollapseIfNeeded(cleanQuestion, config.collapse)}\n`; + const answerBlock = `A:\n${this.wrapHtmlWithCollapseIfNeeded(cleanAnswer, config.collapse)}`; + const finalText = questionBlock + answerBlock + poweredByText; + + return await this.sendHtml(msg, finalText, replyToId, false); + } + const finalText = this.wrapHtmlWithCollapseIfNeeded(text, config.collapse) + poweredByText; + return await this.sendHtml(msg, finalText, replyToId, false); + } + + const qa = text.match(/Q:\n([\s\S]+?)\n\nA:\n([\s\S]+)/); + if (!qa) { + token?.throwIfAborted(); + const finalText = this.wrapHtmlWithCollapseIfNeeded(text, config.collapse) + poweredByText; + return await this.sendHtml(msg, finalText, replyToId, false); + } + + const [, question, answer] = qa; + const answerText = answer.replace(/^A:\n/, ""); + const chunks: string[] = []; + let current = ""; + + for (const line of answerText.split("\n")) { + token?.throwIfAborted(); + const testLength = (current + line + "\n").length; + if (testLength > 4050 && current) { + chunks.push(current); + current = line; + } else { + current += (current ? "\n" : "") + line; + } + } + if (current) chunks.push(current); + + token?.throwIfAborted(); + + const firstMessageContent = + `Q:\n${this.wrapHtmlWithCollapseIfNeeded(question, config.collapse)}\n` + + `A:\n${this.wrapHtmlWithCollapseIfNeeded(chunks[0], config.collapse)}`; + + const firstMessage = await this.sendHtml(msg, firstMessageContent, replyToId); + + for (let idx = 1; idx < chunks.length; idx++) { + if (token?.aborted) break; + await sleep(500, token); + if (token?.aborted) break; + + const isLast = idx === chunks.length - 1; + const wrapped = this.wrapHtmlWithCollapseIfNeeded(chunks[idx], config.collapse); + const prefix = `📋 续 (${idx}/${chunks.length - 1}):\n\n`; + const finalMessage = prefix + wrapped + (isLast ? poweredByText : ""); + + await this.sendHtml(msg, finalMessage, firstMessage.id, false); + } + + return firstMessage; + } + + async sendImages( + msg: Api.Message, + images: AIImage[], + prompt: string, + replyToId?: number, + token?: AbortToken + ): Promise { + const config = (await this.configManagerPromise).getConfig(); + await this.sendMedia(msg, images, prompt, replyToId, token, { + previewEnabled: config.imagePreview, + poweredByTag: config.currentImageTag, + collapse: config.collapse, + directory: "ai_images", + filePrefix: "ai", + getExtension: getImageExtensionForMime, + resolve: (image, mediaToken) => resolveAIImageData(image, this.httpClient, mediaToken), }); - const responseParts = data?.candidates?.[0]?.content?.parts || []; - let text: string | undefined; - let image: Buffer | undefined; - let mime: string | undefined; - for (const part of responseParts) { - const pAny: any = part; - if (pAny?.text) { - const rawText = String(pAny.text); - // 清理思考标签 - const cleanedText = cleanAIThinking(rawText); - - // 尝试从文本中提取内嵌的 data URI 图片 - const embeddedImages = extractEmbeddedImages(cleanedText); - if (embeddedImages.length > 0) { - // 使用第一张提取到的图片 - const firstImg = embeddedImages[0]; - image = Buffer.from(firstImg.data, "base64"); - mime = firstImg.mime; - // 清理图片数据后的文本 - const remainingText = cleanEmbeddedImages(cleanedText).replace(/\[图片\]/g, "").replace(/\[图片数据\]/g, "").trim(); - if (remainingText && remainingText.length > 10) { - text = remainingText; - } - } else { - text = cleanedText; + } + + async sendVideos( + msg: Api.Message, + videos: AIVideo[], + prompt: string, + replyToId?: number, + token?: AbortToken + ): Promise { + const config = (await this.configManagerPromise).getConfig(); + await this.sendMedia(msg, videos, prompt, replyToId, token, { + previewEnabled: config.videoPreview, + poweredByTag: config.currentVideoTag, + collapse: config.collapse, + directory: "ai_videos", + filePrefix: "ai_video", + rawFilePrefix: "ai_video_raw", + getExtension: getVideoExtensionForMime, + resolve: (video, mediaToken) => resolveAIVideoData(video, this.httpClient, mediaToken), + prepareForSend: (rawPath, finalPath) => ensureVideoHasAudio(rawPath, finalPath), + }); + } + + private async sendMedia( + msg: Api.Message, + mediaItems: T[], + prompt: string, + replyToId: number | undefined, + token: AbortToken | undefined, + options: { + previewEnabled: boolean; + poweredByTag: string; + collapse: boolean; + directory: string; + filePrefix: string; + rawFilePrefix?: string; + getExtension: (mimeType: string) => string; + resolve: (item: T, mediaToken?: AbortToken) => Promise<{ data?: Buffer; mimeType: string } | null>; + prepareForSend?: (rawPath: string, finalPath: string) => Promise; + } + ): Promise { + if (!mediaItems.length) return; + + const peerId = msg.chatId || msg.peerId; + const promptText = htmlEscape(prompt); + const promptBlock = options.collapse ? `
${promptText}
` : promptText; + const poweredByText = `\n🍀Powered by ${options.poweredByTag}`; + const caption = promptBlock + poweredByText; + const mediaDir = createDirectoryInAssets(options.directory); + const timestamp = Date.now(); + + for (let i = 0; i < mediaItems.length; i++) { + const item = mediaItems[i]; + token?.throwIfAborted(); + + const resolved = await options.resolve(item, token); + if (!resolved?.data) continue; + + const extension = options.getExtension(resolved.mimeType); + const rawPrefix = options.rawFilePrefix ?? options.filePrefix; + const rawName = `${rawPrefix}_${timestamp}_${i}${extension}`; + const finalName = `${options.filePrefix}_${timestamp}_${i}${extension}`; + const rawPath = path.join(mediaDir, rawName); + const finalPath = path.join(mediaDir, finalName); + + try { + await fs.promises.writeFile(rawPath, resolved.data); + const pathToSend = options.prepareForSend + ? await options.prepareForSend(rawPath, finalPath) + : rawPath; + + if (!msg.client) { + throw new Error("客户端未初始化"); + } + + await msg.client.sendFile(peerId, { + file: pathToSend, + forceDocument: !options.previewEnabled, + caption, + parseMode: "html", + replyTo: replyToId, + }); + } finally { + const cleanupTargets = options.prepareForSend ? [rawPath, finalPath] : [rawPath]; + for (const p of cleanupTargets) { + fs.unlink(p, () => { }); } } - const inline = pAny?.inlineData || pAny?.inline_data; - if (inline?.data) { - image = Buffer.from(inline.data, "base64"); - mime = inline?.mimeType || inline?.mime_type || "image/png"; + } + } + + private async ensureTGToken(config: DB, token?: AbortToken): Promise { + if (config.telegraphToken) return config.telegraphToken; + if (this.telegraphTokenPromise) return this.telegraphTokenPromise; + + this.telegraphTokenPromise = (async () => { + const response = await this.httpClient.request( + { + url: "https://api.telegra.ph/createAccount", + method: "POST", + data: { short_name: "TeleBoxAI", author_name: "TeleBox" }, + }, + token + ); + + const tgToken = response.data?.result?.access_token; + if (!tgToken) throw new Error("Telegraph账户创建失败"); + + const configManager = await this.configManagerPromise; + await configManager.updateConfig((cfg) => { + cfg.telegraphToken = tgToken; + }); + + return tgToken; + })(); + + try { + return await this.telegraphTokenPromise; + } finally { + this.telegraphTokenPromise = null; + } + } + + private wrapHtmlWithCollapseIfNeeded(html: string, collapse: boolean): string { + return collapse ? `
${html}
` : html; + } + + private async sendHtml( + msg: Api.Message, + html: string, + replyToId?: number, + linkPreview?: boolean + ): Promise { + return await MessageSender.sendNew( + msg, + html, + { parseMode: "html", ...(linkPreview === undefined ? {} : { linkPreview }) }, + replyToId + ); + } +} + +interface ConfigChangeListener { + onConfigChanged(config: DB): void | Promise; +} + +class ConfigManager { + private static instancePromise: Promise | null = null; + private listeners: ConfigChangeListener[] = []; + private currentConfig: DB; + private db: Low | null = null; + private baseDir: string = ""; + private file: string = ""; + + private writeQueue: Promise = Promise.resolve(); + + private constructor() { + this.currentConfig = this.getDefaultConfig(); + } + + private getDefaultConfig(): DB { + return { + configs: {}, + currentChatTag: "", + currentChatModel: "", + currentSearchTag: "", + currentSearchModel: "", + currentImageTag: "", + currentImageModel: "", + currentVideoTag: "", + currentVideoModel: "", + imagePreview: true, + videoPreview: true, + videoAudio: false, + videoDuration: 5, + prompt: "", + collapse: true, + timeout: 30, + telegraphToken: "", + telegraph: { enabled: false, limit: 5, list: [] }, + }; + } + + static getInstance(): Promise { + if (ConfigManager.instancePromise) { + return ConfigManager.instancePromise; + } + + ConfigManager.instancePromise = (async () => { + const instance = new ConfigManager(); + await instance.init(); + return instance; + })(); + + return ConfigManager.instancePromise; + } + + private async init(): Promise { + if (this.db) return; + + this.baseDir = createDirectoryInAssets("ai"); + this.file = path.join(this.baseDir, "config.json"); + this.db = await JSONFilePreset(this.file, this.getDefaultConfig()); + + await this.writeQueue; + await this.db.read(); + this.currentConfig = { ...this.db.data }; + const before = JSON.stringify(this.currentConfig); + this.ensureDefaults(); + const after = JSON.stringify(this.currentConfig); + if (before !== after) { + this.db.data = { ...this.currentConfig }; + await this.db.write(); + } + } + + getConfig(): DB { + return { ...this.currentConfig }; + } + + async updateConfig(updater: (config: DB) => void): Promise { + this.writeQueue = this.writeQueue.then(async () => { + const oldSnapshot: DB = JSON.parse(JSON.stringify(this.currentConfig)); + updater(this.currentConfig); + + const hasChanged = JSON.stringify(oldSnapshot) !== JSON.stringify(this.currentConfig); + + if (!hasChanged) { + return; + } + + if (this.db) { + this.db.data = { ...this.currentConfig }; + await this.db.write(); } - const fileUri = pAny?.fileData?.fileUri || pAny?.file_data?.file_uri; - if (fileUri) { - const hint = `生成的图片已提供文件URI:${String(fileUri)}`; - text = text ? `${text}\n${hint}` : hint; + await this.notifyListeners(this.currentConfig); + }); + return this.writeQueue; + } + + registerListener(listener: ConfigChangeListener): void { + this.listeners.push(listener); + } + + unregisterListener(listener: ConfigChangeListener): void { + const idx = this.listeners.indexOf(listener); + if (idx > -1) this.listeners.splice(idx, 1); + } + + async destroy(): Promise { + this.listeners = []; + ConfigManager.instancePromise = null; + this.db = null; + } + + private ensureDefaults(): void { + const cfg = this.currentConfig; + + if (!cfg.currentSearchTag && cfg.currentChatTag) cfg.currentSearchTag = cfg.currentChatTag; + if (!cfg.currentSearchModel && cfg.currentChatModel) cfg.currentSearchModel = cfg.currentChatModel; + if (!cfg.currentImageTag && cfg.currentChatTag) cfg.currentImageTag = cfg.currentChatTag; + if (!cfg.currentImageModel && cfg.currentChatModel) cfg.currentImageModel = cfg.currentChatModel; + if (!cfg.currentVideoTag && cfg.currentChatTag) cfg.currentVideoTag = cfg.currentChatTag; + if (!cfg.currentVideoModel && cfg.currentChatModel) cfg.currentVideoModel = cfg.currentChatModel; + + if (typeof cfg.imagePreview !== "boolean") cfg.imagePreview = true; + if (typeof cfg.videoPreview !== "boolean") cfg.videoPreview = true; + if (typeof cfg.videoAudio !== "boolean") cfg.videoAudio = false; + if (typeof cfg.videoDuration !== "number" || !Number.isFinite(cfg.videoDuration)) cfg.videoDuration = 5; + if (cfg.videoDuration < 5 || cfg.videoDuration > 20) cfg.videoDuration = 5; + if (typeof cfg.collapse !== "boolean") cfg.collapse = true; + if (typeof cfg.timeout !== "number" || !Number.isFinite(cfg.timeout) || cfg.timeout <= 0) { + cfg.timeout = 30; + } + + if (!cfg.telegraph || typeof cfg.telegraph !== "object") { + cfg.telegraph = { enabled: false, limit: 5, list: [] }; + } else { + if (typeof cfg.telegraph.enabled !== "boolean") cfg.telegraph.enabled = false; + if (typeof cfg.telegraph.limit !== "number" || cfg.telegraph.limit <= 0) cfg.telegraph.limit = 5; + if (!Array.isArray(cfg.telegraph.list)) { + cfg.telegraph.list = []; + } else { + cfg.telegraph.list = cfg.telegraph.list.filter( + (item): item is TelegraphItem => + !!item && typeof item.url === "string" && typeof item.title === "string" && typeof item.createdAt === "string" + ); } } - return { image, text, mime }; - } catch (err: any) { - const body = err?.response?.data; - const msg = body?.error?.message || body?.message || err?.message || String(err); - throw new Error(`图片生成失败:${msg}`); } + + private async notifyListeners(newConfig: DB): Promise { + for (const listener of this.listeners) await listener.onConfigChanged(newConfig); + } +} + +const resolveAuthMode = ( + profile: ProviderProfile, + modeConfig: ProviderModeConfig, + config?: ProviderConfig +): AuthMode => { + if (modeConfig.authMode) return modeConfig.authMode; + if (profile.authMode) return profile.authMode; + const host = config ? getProviderHost(config.url) : null; + if (host === "generativelanguage.googleapis.com") return "query-key"; + return "bearer"; +}; + +const applyAuthConfig = ( + authMode: AuthMode, + config: ProviderConfig, + url: string, + headers: Record +): { url: string; headers: Record } => { + if (authMode === "query-key") { + try { + const u = new URL(url); + if (!u.searchParams.has("key")) u.searchParams.set("key", config.key); + return { url: u.toString(), headers }; + } catch { + return { url, headers }; + } + } + return { + url, + headers: { + ...headers, + Authorization: `Bearer ${config.key}`, + }, + }; }; -/* ---------- TTS ---------- */ -const ttsGemini = async (p: Provider, model: string, input: string, voiceName?: string): Promise<{ audio?: Buffer; mime?: string }> => { - const path = `/models/${encodeURIComponent(model)}:generateContent`; - const voice = voiceName || "Kore"; - const buildPayloads = () => [ - { - contents: [{ role: "user", parts: [{ text: input }] }], - generationConfig: { - responseModalities: ["AUDIO"], - speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } } +const normalizeOpenAIBaseUrl = (url: string): string => { + try { + const u = new URL(url); + + if (u.hostname.includes("gateway.ai.cloudflare.com")) { + const openAiIndex = u.pathname.indexOf("/openai"); + if (openAiIndex >= 0) { + u.pathname = u.pathname.slice(0, openAiIndex + "/openai".length); } - }, - { - contents: [{ role: "user", parts: [{ text: input }] }], - generationConfig: { responseModalities: ["AUDIO"] } + u.search = ""; + return u.toString(); } - ]; - try { - const payloads = buildPayloads(); - for (let i = 0; i < payloads.length; i++) { - const payload = payloads[i]; - try { - const data = await geminiRequestWithFallback(p, path, { - method: "POST", - data: payload, - params: { key: p.apiKey }, - timeout: 60000 - }); - const parts = data?.candidates?.[0]?.content?.parts || []; - for (const part of parts) { - const pAny: any = part; - const inline = pAny?.inlineData || pAny?.inline_data; - const d = inline?.data; - const m = inline?.mimeType || inline?.mime_type || "audio/ogg"; - if (d && String(m).startsWith("audio/")) { - const audio = Buffer.from(d, "base64"); - const mime = m; - return { audio, mime }; - } - } - } catch { - // Payload 失败,尝试下一个 + + const stripSuffixes = [ + "/chat/completions", + "/completions", + "/responses", + "/messages", + "/images/generations", + ]; + for (const s of stripSuffixes) { + if (u.pathname.endsWith(s)) { + u.pathname = u.pathname.slice(0, -s.length); + break; } } - return {}; + + const apiV1Index = u.pathname.indexOf("/api/v1"); + if (apiV1Index >= 0) { + u.pathname = u.pathname.slice(0, apiV1Index + "/api/v1".length); + u.search = ""; + return u.toString(); + } + + const v1Index = u.pathname.indexOf("/v1"); + if (v1Index >= 0) { + u.pathname = u.pathname.slice(0, v1Index + "/v1".length); + u.search = ""; + return u.toString(); + } + + u.pathname = "/v1"; + u.search = ""; + return u.toString(); } catch { - return {}; + return url; } }; -const ttsOpenAI = async (p: Provider, model: string, input: string, voiceName?: string): Promise => { - const base = trimBase(p.baseUrl); - const paths = ["/v1/audio/speech", "/v1/audio/tts", "/audio/speech"]; - const payload = { model, input, voice: voiceName || "alloy", format: "opus" }; - const attempts = buildAuthAttempts(p, { "Content-Type": "application/json" }); - let lastErr: any; - for (const pth of paths) { - const url = base + pth; - for (const a of attempts) { - try { - const r = await axiosWithRetry({ method: "POST", url, data: payload, responseType: "arraybuffer", ...(a || {}), timeout: 60000 }); - const data: any = r.data; - const buf: Buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); - if (buf && buf.length > 0) return buf; - } catch (err: any) { - lastErr = err; + +const normalizeGeminiBaseUrl = (url: string): string => { + try { + const u = new URL(url); + u.pathname = u.pathname.replace(/\/+$/, ""); + if (u.pathname === "" || u.pathname === "/") { + u.pathname = "/v1beta"; + } + if (!u.pathname.startsWith("/v1beta")) { + u.pathname = "/v1beta"; + } + u.search = ""; + u.hash = ""; + return u.toString(); + } catch { + return url; + } +}; + +const parseOpenAIChatResponse = (data: any): { text: string; images: AIImage[] } => { + const message = data?.choices?.[0]?.message; + if (!message) return { text: "AI回复为空", images: [] }; + + if (typeof message.content === "string") { + return { text: message.content || "AI回复为空", images: [] }; + } + + if (Array.isArray(message.content)) { + const textSegments: string[] = []; + const images: AIImage[] = []; + for (const part of message.content as AIContentPart[]) { + if (part.type === "text") textSegments.push(part.text); + if (part.type === "image_url") { + const dataUrl = parseDataUrl(part.image_url.url); + if (dataUrl) images.push({ data: dataUrl.data, mimeType: dataUrl.mimeType }); + else images.push({ url: part.image_url.url, mimeType: "image/jpeg" }); } } - } - const status = lastErr?.response?.status; - const bodyErr = lastErr?.response?.data; - const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); - throw new Error(`[ttsOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); -}; + const text = textSegments.join("\n").trim(); + return { text, images }; + } + + return { text: "AI回复为空", images: [] }; +}; + +const parseOpenAIStyleImageResponse = (data: any): AIImage[] => { + const images: AIImage[] = []; + const list = data?.data || []; + for (const item of list) { + if (item?.b64_json) { + images.push({ data: Buffer.from(item.b64_json, "base64"), mimeType: "image/png" }); + } else if (item?.url) { + images.push({ url: item.url, mimeType: "image/png" }); + } + } + return images; +}; + +const buildDoubaoVideoUrl = (data: any): string | null => { + return ( + data?.data?.result?.video_url || + data?.data?.output?.video_url || + data?.data?.video_url || + data?.video_url || + data?.content?.video_url || + data?.data?.content?.video_url || + null + ); +}; + +const buildGeminiVideoApiUrl = (baseUrl: string, model: string, key: string, endpoint?: string): string => { + const urlObj = new URL(baseUrl); + const finalModel = model || "veo-2.0-generate-001"; + const endpointTemplate = endpoint || "v1beta/models/{model}:generateVideos"; + urlObj.pathname = endpointTemplate.replace("{model}", finalModel).replace(/^\/+/, "/"); + urlObj.searchParams.set("key", key); + return urlObj.toString(); +}; + +const buildGeminiOperationUrl = (baseOrigin: string, name: string, key: string): string => { + const urlObj = new URL(baseOrigin); + const cleanName = name.replace(/^\/+/, ""); + const path = cleanName.startsWith("v1beta/") ? cleanName : `v1beta/${cleanName}`; + urlObj.pathname = `/${path}`; + urlObj.searchParams.set("key", key); + return urlObj.toString(); +}; + +const extractGeminiOperationError = (data: any): string => { + const err = data?.error || data?.data?.error; + if (!err) return ""; + if (typeof err === "string") return err; + if (typeof err.message === "string") return err.message; + if (typeof err.status === "string") return err.status; + if (Array.isArray(err.details) && err.details.length > 0) { + const detail = err.details[0]; + if (typeof detail?.message === "string") return detail.message; + } + return "视频生成失败"; +}; + +const extractGeminiVideoResult = (data: any): { uri?: string; bytes?: string } | null => { + const response = data?.response ?? data?.data?.response ?? data; + const sampleUri = + response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri || + response?.generate_video_response?.generated_samples?.[0]?.video?.uri; + if (sampleUri) return { uri: sampleUri }; + + const videoBytes = + response?.generatedVideos?.[0]?.video?.videoBytes || + response?.generated_videos?.[0]?.video?.video_bytes || + response?.generatedVideos?.[0]?.video?.video_bytes || + response?.generated_videos?.[0]?.video?.videoBytes; + if (videoBytes) return { bytes: videoBytes }; + + return null; +}; + +const buildGeminiParts = async ( + prompt: string, + images: AIContentPart[], + httpClient: HttpClient, + token?: AbortToken +): Promise>> => { + const parts: Array> = []; + if (prompt.trim()) parts.push({ text: prompt }); + + const resolvedImages = await resolveImageInputs(images, httpClient, token, { allowFailures: true }); + for (const image of resolvedImages) { + parts.push({ + inlineData: { + data: image.data.toString("base64"), + mimeType: image.mimeType, + }, + }); + } + + return parts; +}; + +class FeatureRegistry { + private features = new Map(); + + register(handler: FeatureHandler): void { + this.features.set(handler.command.toLowerCase(), handler); + } + + getHandler(command: string): FeatureHandler | undefined { + return this.features.get(command.toLowerCase()); + } +} + +class MiddlewarePipeline { + private middlewares: Middleware[] = []; + + use(middleware: Middleware): void { + this.middlewares.push(middleware); + } + + async execute( + input: T, + finalHandler: (input: T, token?: AbortToken) => Promise, + token?: AbortToken + ): Promise { + const exec = async (idx: number, curInput: T, curToken?: AbortToken): Promise => { + if (idx >= this.middlewares.length) return await finalHandler(curInput, curToken); + const mw = this.middlewares[idx]; + return await mw.process(curInput, (nextInput, nextToken) => exec(idx + 1, nextInput, nextToken), curToken); + }; + return await exec(0, input, token); + } +} + +class TimeoutMiddleware implements Middleware { + private configManagerPromise: Promise; + + constructor(configManagerPromise: Promise) { + this.configManagerPromise = configManagerPromise; + } + + async process( + input: T, + next: (input: T, token?: AbortToken) => Promise, + token?: AbortToken + ): Promise { + const config = (await this.configManagerPromise).getConfig(); + const timeoutMs = config.timeout * 1000; + + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(`请求超时: ${timeoutMs}ms`), timeoutMs); + + try { + const combined = this.combine(timeoutController, token); + combined.signal.addEventListener("abort", () => clearTimeout(timeoutId), { once: true }); + return await next(input, combined); + } finally { + clearTimeout(timeoutId); + } + } + + private combine(timeoutController: AbortController, externalToken?: AbortToken): AbortToken { + const controller = new AbortController(); + + if (timeoutController.signal.aborted) controller.abort(timeoutController.signal.reason); + else + timeoutController.signal.addEventListener("abort", () => controller.abort(timeoutController.signal.reason), { + once: true, + }); + + if (externalToken) { + if (externalToken.aborted) controller.abort(externalToken.reason); + else + externalToken.signal.addEventListener("abort", () => controller.abort(externalToken.reason), { + once: true, + }); + } + + return { + get aborted() { + return controller.signal.aborted; + }, + get reason() { + return controller.signal.reason?.toString(); + }, + get signal() { + return controller.signal; + }, + abort(reason?: string) { + controller.abort(reason); + }, + throwIfAborted() { + if (controller.signal.aborted) { + throw new UserError(controller.signal.reason?.toString() || "操作已取消"); + } + }, + }; + } +} + +class HttpClient { + private axiosInstance: AxiosInstance; + private middlewarePipeline: MiddlewarePipeline; + + constructor(configManagerPromise: Promise) { + const keepAliveAgent = { + httpAgent: new http.Agent({ keepAlive: true }), + httpsAgent: new https.Agent({ keepAlive: true }), + }; + this.axiosInstance = axios.create(keepAliveAgent); + + this.middlewarePipeline = new MiddlewarePipeline(); + this.middlewarePipeline.use(new TimeoutMiddleware(configManagerPromise)); + } + + async request(requestConfig: AxiosRequestConfig, token?: AbortToken): Promise> { + return await this.middlewarePipeline.execute( + requestConfig, + async (config: AxiosRequestConfig, pipelineToken?: AbortToken) => { + const finalConfig: AxiosRequestConfig = { + ...config, + signal: pipelineToken?.signal ?? config.signal, + }; + return await this.axiosInstance(finalConfig); + }, + token + ); + } +} + +class AIService implements ConfigChangeListener { + private configManager?: ConfigManager; + private configManagerPromise: Promise; + private activeTokens: Set = new Set(); + private httpClient: HttpClient; + private strategyHandlers: Record; + + constructor(configManagerPromise: Promise, httpClient: HttpClient) { + this.configManagerPromise = configManagerPromise; + this.httpClient = httpClient; + this.strategyHandlers = this.createStrategyHandlers(); + + this.initConfigListener(); + } + + private async initConfigListener(): Promise { + this.configManager = await this.configManagerPromise; + this.configManager.registerListener(this); + } + + private async getConfigManager(): Promise { + if (this.configManager) return this.configManager; + this.configManager = await this.configManagerPromise; + return this.configManager; + } + + async onConfigChanged(_config: DB): Promise { } + + private async getCurrentProviderConfig( + type: "chat" | "search" | "image" | "video" + ): Promise<{ providerConfig: ProviderConfig; model: string; config: DB }> { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + const tag = + type === "chat" + ? config.currentChatTag + : type === "search" + ? config.currentSearchTag + : type === "image" + ? config.currentImageTag + : config.currentVideoTag; + + const model = + type === "chat" + ? config.currentChatModel + : type === "search" + ? config.currentSearchModel + : type === "image" + ? config.currentImageModel + : config.currentVideoModel; + + if (!tag || !model || !config.configs[tag]) { + throw new UserError("请先配置API并设置模型"); + } + + return { providerConfig: config.configs[tag], model, config }; + } + + private resolveMode( + providerConfig: ProviderConfig, + mode: ProviderMode, + model: string + ): { profile: ProviderProfile; modeConfig: ProviderModeConfig } { + const profile = getProviderProfile(providerConfig.url); + const modeConfig = resolveModeConfig(profile, mode, model); + if (!modeConfig) { + throw new UserError(`当前${profile.id}提供商不支持${mode}模式`); + } + return { profile, modeConfig }; + } + + private applyImageDefaults( + request: Record, + providerConfig: ProviderConfig, + model: string, + modeConfig: ProviderModeConfig + ): void { + if (modeConfig.imageDefaults?.size) request.size = modeConfig.imageDefaults.size; + if (modeConfig.imageDefaults?.quality) request.quality = modeConfig.imageDefaults.quality; + if (modeConfig.imageDefaults?.responseFormat) { + request.responseFormat = modeConfig.imageDefaults.responseFormat; + request.response_format = modeConfig.imageDefaults.responseFormat; + } + if (modeConfig.imageDefaults?.extraParams) Object.assign(request, modeConfig.imageDefaults.extraParams); + + const host = getProviderHost(providerConfig.url); + if (host === "api.openai.com") { + if (!model.startsWith("gpt-") && !model.includes("chatgpt-image")) { + request.responseFormat = "b64_json"; + request.response_format = "b64_json"; + } + if (!request.size) request.size = "auto"; + if (model.startsWith("dall-e-3")) { + request.quality = "hd"; + } else if (model.startsWith("gpt-image")) { + request.quality = "high"; + } + } + } + + private applyVideoDefaults(request: Record, modeConfig: ProviderModeConfig): void { + if (modeConfig.videoDefaults?.responseFormat) { + request.responseFormat = modeConfig.videoDefaults.responseFormat; + request.response_format = modeConfig.videoDefaults.responseFormat; + } + if (modeConfig.videoDefaults?.extraParams) Object.assign(request, modeConfig.videoDefaults.extraParams); + } + + private createStrategyHandlers(): Record { + return { + "openai-rest": { + chat: async (ctx) => + this.callOpenAIChatOrSearch( + ctx.providerConfig, + ctx.model, + ctx.question, + ctx.images, + ctx.modeConfig, + ctx.config.prompt || "", + ctx.token + ), + search: async (ctx) => + this.callOpenAIChatOrSearch( + ctx.providerConfig, + ctx.model, + ctx.question, + ctx.images, + ctx.modeConfig, + ctx.config.prompt || "", + ctx.token, + true + ), + image: async (ctx) => + this.generateImageWithOpenAIRest( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.image, + ctx.modeConfig, + ctx.token + ), + video: async (ctx) => + this.generateVideoWithOpenAIRest( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.images, + ctx.imageMode, + ctx.modeConfig, + ctx.token + ), + }, + "gemini-rest": { + chat: async (ctx) => + this.callGeminiChatOrSearch( + ctx.providerConfig, + ctx.model, + ctx.question, + ctx.images, + ctx.modeConfig, + ctx.config.prompt || "", + ctx.token + ), + search: async (ctx) => + this.callGeminiChatOrSearch( + ctx.providerConfig, + ctx.model, + ctx.question, + ctx.images, + ctx.modeConfig, + ctx.config.prompt || "", + ctx.token, + true + ), + image: async (ctx) => + this.generateGeminiImageRest( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.modeConfig, + ctx.image, + ctx.token + ), + }, + "doubao-rest": { + image: async (ctx) => + this.generateImageWithDoubao( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.image, + ctx.modeConfig, + ctx.token + ), + video: async (ctx) => + this.generateVideoWithDoubao( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.images, + ctx.imageMode, + ctx.config.videoAudio, + ctx.config.videoDuration, + ctx.modeConfig, + ctx.token + ), + }, + "gemini-image-rest": { + image: async (ctx) => + this.generateGeminiImageRest( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.modeConfig, + ctx.image, + ctx.token + ), + }, + "gemini-video-rest": { + video: async (ctx) => + this.generateGeminiVideo( + ctx.providerConfig, + ctx.model, + ctx.prompt, + ctx.images, + ctx.config.videoAudio, + ctx.config.videoDuration, + ctx.modeConfig, + ctx.token + ), + }, + }; + } + + private async callOpenAIChatOrSearch( + providerConfig: ProviderConfig, + model: string, + question: string, + images: AIContentPart[], + modeConfig: ProviderModeConfig, + systemPrompt: string, + token?: AbortToken, + isSearch = false + ): Promise<{ text: string; sources: Array<{ url: string; title?: string }>; images: AIImage[] }> { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + const url = resolveEndpointUrl(baseUrl, modeConfig.endpoint || "chat/completions"); + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + + const imageUrlPolicy = modeConfig.imageUrlPolicy ?? "any"; + const safeImages = + imageUrlPolicy === "data-only" + ? images.filter((part) => part.type === "image_url" && !!parseDataUrl(part.image_url.url)) + : images; + + const authConfig = applyAuthConfig(authMode, providerConfig, url, { "Content-Type": "application/json" }); + + const sys = (systemPrompt || "").trim(); + + const messages: any[] = []; + if (sys) messages.push({ role: "system", content: sys }); + + let userContent: any = []; + if (question.trim()) userContent.push({ type: "text", text: question.trim() }); + + for (const img of safeImages) { + if (img.type === "image_url") { + userContent.push(img); + } + } + + if (userContent.length === 0) userContent = question; + else if (userContent.length === 1 && userContent[0].type === "text") userContent = userContent[0].text; + + messages.push({ + role: "user", + content: userContent, + }); + + const data: any = { + model, + messages, + stream: false, + }; + + if (isSearch) { + data.tools = [ + { + type: "web_search", + web_search: { + searchContextSize: "high" + } + } + ]; + data.web_search_options = { search_context_size: "high" }; + } + + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + const parsed = parseOpenAIChatResponse(response.data); + + let sources: Array<{ url: string; title?: string }> = []; + if (isSearch) { + const msg = response.data?.choices?.[0]?.message; + if (msg?.annotations) { + sources = msg.annotations + .filter((a: any) => a.type === "url_citation" || a.url_citation) + .map((a: any) => ({ + url: a.url_citation?.url || a.url, + title: a.url_citation?.title || a.title + })) + .filter((a: any) => !!a.url); + } else if (msg?.citations) { + sources = msg.citations.map((c: any) => ({ url: c.url, title: c.title })).filter((c: any) => !!c.url); + } else if (response.data?.citations) { + sources = response.data.citations.map((c: any) => ({ url: c.url, title: c.title })).filter((c: any) => !!c.url); + } + } + + return { text: parsed.text, images: parsed.images, sources }; + } + + private async callGeminiChatOrSearch( + providerConfig: ProviderConfig, + model: string, + question: string, + images: AIContentPart[], + modeConfig: ProviderModeConfig, + systemPrompt: string, + token?: AbortToken, + isSearch = false + ): Promise<{ text: string; sources: Array<{ url: string; title?: string }>; images: AIImage[] }> { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + const endpoint = (modeConfig.endpoint || "models/{model}:generateContent").replace("{model}", model); + const url = resolveEndpointUrl(baseUrl, endpoint); + + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + const authConfig = applyAuthConfig(authMode, providerConfig, url, { "Content-Type": "application/json" }); + + const parts = await buildGeminiParts(question, images, this.httpClient, token); + + const data: any = { + contents: [{ role: "user", parts }] + }; + + if (systemPrompt?.trim()) { + data.systemInstruction = { + role: "system", + parts: [{ text: systemPrompt.trim() }] + }; + } + + if (isSearch) { + data.tools = [{ googleSearch: {} }]; + } + + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + const root = response.data?.response ?? response.data?.data ?? response.data; + const candidate = root?.candidates?.[0]; + const cparts = candidate?.content?.parts ?? []; + + let text = ""; + let extractedImages: AIImage[] = []; + + for (const p of cparts) { + if (p.text) text += p.text; + const inline = p.inlineData || p.inline_data; + if (inline?.data) { + extractedImages.push({ + data: Buffer.from(inline.data, "base64"), + mimeType: inline.mimeType || inline.mime_type || "image/png", + }); + } + } + + let sources: Array<{ url: string; title?: string }> = []; + if (isSearch) { + const groundingMetadata = candidate?.groundingMetadata || candidate?.grounding_metadata; + const groundingChunks = groundingMetadata?.groundingChunks || groundingMetadata?.grounding_chunks || []; + for (const chunk of groundingChunks) { + const web = chunk.web || chunk.web_chunk; + if (web?.uri) { + sources.push({ url: web.uri, title: web.title }); + } + } + } + + return { text: text.trim() || "AI回复为空", images: extractedImages, sources }; + } + + private async generateImageWithDoubao( + providerConfig: ProviderConfig, + model: string, + prompt: string, + image: AIImage | undefined, + modeConfig: ProviderModeConfig, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + + const data: Record = { + prompt, + model, + }; + if (image) { + if (!image.data) throw new Error("无法解析图片数据"); + data.image = `data:${image.mimeType};base64,${image.data.toString("base64")}`; + } + if (modeConfig.imageDefaults?.size) data.size = modeConfig.imageDefaults.size; + if (modeConfig.imageDefaults?.responseFormat) data.response_format = modeConfig.imageDefaults.responseFormat; + if (modeConfig.imageDefaults?.extraParams) Object.assign(data, modeConfig.imageDefaults.extraParams); + + const endpoint = modeConfig.endpoint || "api/v3/images/generations"; + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + const authConfig = applyAuthConfig(authMode, providerConfig, resolveEndpointUrl(baseUrl, endpoint), { + "Content-Type": "application/json", + }); + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + return parseOpenAIStyleImageResponse(response.data); + } + + private async generateImageWithOpenAIRest( + providerConfig: ProviderConfig, + model: string, + prompt: string, + image: AIImage | undefined, + modeConfig: ProviderModeConfig, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + let endpoint = modeConfig.endpoint || "images/generations"; + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + + const requestModel = model; + + let data: any; + let headers: Record = {}; + + if (image && image.data) { + const dataUri = `data:${image.mimeType};base64,${image.data.toString("base64")}`; + + if (model.includes("gpt-image") || model.includes("chatgpt-image") || model.includes("dall-e")) { + endpoint = modeConfig.endpoint || "images/edits"; + data = { + model: requestModel, + prompt, + images: [ + { + image_url: dataUri, + }, + ], + }; + this.applyImageDefaults(data, providerConfig, requestModel, modeConfig); + headers["Content-Type"] = "application/json"; + } else { + endpoint = modeConfig.endpoint || "images/edits"; + const fields: Record = { + model: requestModel, + prompt, + }; + this.applyImageDefaults(fields, providerConfig, requestModel, modeConfig); + + const boundary = "----WebKitFormBoundary" + Math.random().toString(36).substring(2); + const chunks: Buffer[] = []; + + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) { + chunks.push(Buffer.from(`--${boundary}\r\n`)); + chunks.push(Buffer.from(`Content-Disposition: form-data; name="${key}"\r\n\r\n`)); + chunks.push(Buffer.from(`${value}\r\n`)); + } + } + + chunks.push(Buffer.from(`--${boundary}\r\n`)); + chunks.push(Buffer.from(`Content-Disposition: form-data; name="image"; filename="image.png"\r\n`)); + chunks.push(Buffer.from(`Content-Type: ${image.mimeType || "image/png"}\r\n\r\n`)); + chunks.push(image.data); + chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`)); + + data = Buffer.concat(chunks); + headers["Content-Type"] = `multipart/form-data; boundary=${boundary}`; + } + } else { + endpoint = modeConfig.endpoint || "images/generations"; + data = { + model: requestModel, + prompt, + }; + this.applyImageDefaults(data, providerConfig, requestModel, modeConfig); + headers["Content-Type"] = "application/json"; + } + + const authConfig = applyAuthConfig(authMode, providerConfig, resolveEndpointUrl(baseUrl, endpoint), headers); + + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + return parseOpenAIStyleImageResponse(response.data); + } + + private async generateVideoWithOpenAIRest( + providerConfig: ProviderConfig, + model: string, + prompt: string, + images: AIContentPart[], + imageMode: VideoImageMode, + modeConfig: ProviderModeConfig, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + const url = resolveEndpointUrl(baseUrl, modeConfig.endpoint || "chat/completions"); + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + + const authConfig = applyAuthConfig(authMode, providerConfig, url, { "Content-Type": "application/json" }); + + const content: any[] = []; + if (prompt.trim()) { + content.push({ type: "text", text: prompt.trim() }); + } + + const safeImages = images.filter((part) => part.type === "image_url" && !!parseDataUrl(part.image_url.url)); + for (const img of safeImages) { + content.push(img); + } + + let userContent: any = content; + if (content.length === 1 && content[0].type === "text") { + userContent = content[0].text; + } else if (content.length === 0) { + userContent = prompt || "Generate a video"; + } + + const data: any = { + model, + messages: [{ role: "user", content: userContent }], + stream: false, + }; + + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + let replyText = ""; + if (typeof response.data === "string") { + const lines = response.data.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("data: ") && trimmed !== "data: [DONE]") { + try { + const parsed = JSON.parse(trimmed.slice(6)); + replyText += parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.message?.content || ""; + } catch (e) {} + } + } + if (!replyText) replyText = response.data; + } else { + replyText = response.data?.choices?.[0]?.message?.content || ""; + } + + if (!replyText) { + throw new Error("视频生成失败,AI返回为空"); + } + + const match = replyText.match(/(https?:\/\/[^\s"'>]+\.(?:mp4|webm))/i); + if (match && match[1]) { + const isWebm = match[1].toLowerCase().endsWith('.webm'); + return [{ url: match[1], mimeType: isWebm ? "video/webm" : "video/mp4" }]; + } + + throw new Error(`未能从返回结果中提取到视频链接。\nAI返回: ${replyText}`); + } + + private buildDoubaoVideoContent( + prompt: string, + images: AIContentPart[], + imageMode: VideoImageMode + ): Array> { + const content: Array> = []; + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt) { + content.push({ type: "text", text: trimmedPrompt }); + } + + const imageParts = images.filter((part) => part.type === "image_url" && !!parseDataUrl(part.image_url.url)); + const imageCount = imageParts.length; + + for (const [index, part] of imageParts.entries()) { + if (part.type !== "image_url") continue; + const item: Record = { + type: "image_url", + image_url: { url: part.image_url.url }, + }; + if (imageMode === "first") { + item.role = "first_frame"; + } else if (imageMode === "firstlast") { + item.role = index === 0 ? "first_frame" : "last_frame"; + } else if (imageMode === "reference") { + item.role = "reference_image"; + } else if (imageCount === 2) { + item.role = index === 0 ? "first_frame" : "last_frame"; + } else if (imageCount > 2) { + item.role = "reference_image"; + } + content.push(item); + } + + return content; + } + + private async generateGeminiImageRest( + providerConfig: ProviderConfig, + model: string, + prompt: string, + modeConfig: ProviderModeConfig, + image?: AIImage, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + + if (model.includes("imagen")) { + const endpoint = `v1beta/models/${model}:predict`; + const url = resolveEndpointUrl(baseUrl, endpoint); + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + const authConfig = applyAuthConfig(authMode, providerConfig, url, { "Content-Type": "application/json" }); + + const data: any = { + instances: [{ prompt: prompt || "" }], + parameters: { + sampleCount: 1, + outputOptions: { mimeType: "image/png" } + } + }; + + const response = await this.httpClient.request({ + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, token); + + const predictions = response.data?.predictions || []; + const images: AIImage[] = []; + for (const p of predictions) { + if (p.bytesBase64Encoded) { + images.push({ data: Buffer.from(p.bytesBase64Encoded, "base64"), mimeType: p.mimeType || "image/png" }); + } + } + if (images.length === 0) throw new Error("图片生成失败"); + return images; + } + + const endpoint = (modeConfig.endpoint || "models/{model}:generateContent").replace("{model}", model); + const url = resolveEndpointUrl(baseUrl, endpoint); + + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + const authConfig = applyAuthConfig(authMode, providerConfig, url, { "Content-Type": "application/json" }); + + const parts: any[] = []; + if (prompt?.trim()) parts.push({ text: prompt.trim() }); + + if (image?.data) { + parts.push({ + inlineData: { + data: image.data.toString("base64"), + mimeType: image.mimeType || "image/png", + }, + }); + } + + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data: { + contents: [{ parts }], + }, + }, + token + ); + + const root = response.data?.response ?? response.data?.data ?? response.data; + const candidates = root?.candidates ?? []; + const images: AIImage[] = []; + + for (const c of candidates) { + const cparts = c?.content?.parts ?? []; + for (const p of cparts) { + const inline = p?.inlineData || p?.inline_data; + if (inline?.data) { + images.push({ + data: Buffer.from(inline.data, "base64"), + mimeType: inline.mimeType || inline.mime_type || "image/png", + }); + } + } + } + + if (images.length === 0) { + throw new Error("未在 candidates[].content.parts[].inlineData 中找到图片数据"); + } + + return images; + } + + private async generateGeminiVideo( + providerConfig: ProviderConfig, + model: string, + prompt: string, + images: AIContentPart[], + videoAudio: boolean, + videoDuration: number, + modeConfig: ProviderModeConfig, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + const apiUrl = buildGeminiVideoApiUrl(baseUrl, model, providerConfig.key, modeConfig.endpoint); + const parts = await buildGeminiParts(prompt, images, this.httpClient, token); + + const response = await this.httpClient.request( + { + url: apiUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: { + contents: [ + { + parts, + }, + ], + videoGenerationConfig: { + numberOfVideos: 1, + durationSeconds: videoDuration, + enableAudio: videoAudio, + }, + }, + }, + token + ); + + const directResult = extractGeminiVideoResult(response.data); + if (directResult?.bytes) { + return [{ data: Buffer.from(directResult.bytes, "base64"), mimeType: "video/mp4" }]; + } + + if (directResult?.uri) { + const download = await this.httpClient.request( + { + url: directResult.uri, + method: "GET", + responseType: "arraybuffer", + }, + token + ); + const contentType = download.headers?.["content-type"]?.split(";")[0] || "video/mp4"; + return [{ data: Buffer.from(download.data), mimeType: contentType }]; + } + + const operationName = response.data?.name; + if (!operationName || typeof operationName !== "string") { + throw new Error("视频生成失败"); + } + + const baseOrigin = normalizeGeminiBaseUrl(providerConfig.url); + const operation = await pollTask( + async (abortToken) => { + const url = buildGeminiOperationUrl(baseOrigin, operationName, providerConfig.key); + const opResponse = await this.httpClient.request( + { + url, + method: "GET", + headers: { "Content-Type": "application/json" }, + }, + abortToken + ); + return opResponse.data; + }, + (data): TaskPollResult => { + if (!data || data.done !== true) { + return { status: "pending" }; + } + if (data.error) { + return { status: "failed", errorMessage: extractGeminiOperationError(data) }; + } + return { status: "succeeded", result: data }; + }, + { + maxAttempts: 303, + intervalMs: 2000, + }, + token + ); + + const finalResult = extractGeminiVideoResult(operation); + if (finalResult?.bytes) { + return [{ data: Buffer.from(finalResult.bytes, "base64"), mimeType: "video/mp4" }]; + } + if (finalResult?.uri) { + const download = await this.httpClient.request( + { + url: finalResult.uri, + method: "GET", + responseType: "arraybuffer", + }, + token + ); + const contentType = download.headers?.["content-type"]?.split(";")[0] || "video/mp4"; + return [{ data: Buffer.from(download.data), mimeType: contentType }]; + } + + throw new Error("视频生成失败"); + } + + private async generateVideoWithDoubao( + providerConfig: ProviderConfig, + model: string, + prompt: string, + images: AIContentPart[], + imageMode: VideoImageMode, + videoAudio: boolean, + videoDuration: number, + modeConfig: ProviderModeConfig, + token?: AbortToken + ): Promise { + const baseUrl = resolveBaseUrl(providerConfig, modeConfig); + + const content = this.buildDoubaoVideoContent(prompt, images, imageMode); + const data: Record = { + model, + content, + generateAudio: videoAudio, + duration: videoDuration, + }; + this.applyVideoDefaults(data, modeConfig); + + const endpoint = modeConfig.endpoint || "api/v3/contents/generations/tasks"; + const authMode = resolveAuthMode(getProviderProfile(providerConfig.url), modeConfig, providerConfig); + const authConfig = applyAuthConfig(authMode, providerConfig, resolveEndpointUrl(baseUrl, endpoint), { + "Content-Type": "application/json", + }); + const response = await this.httpClient.request( + { + url: authConfig.url, + method: "POST", + headers: authConfig.headers, + data, + }, + token + ); + + const taskId = + response.data?.task_id || + response.data?.data?.task_id || + response.data?.data?.id || + response.data?.id; + if (!taskId) throw new Error("视频生成任务创建失败"); + + const videoUrl = await pollTask( + async (abortToken) => { + const pollUrl = resolveEndpointUrl(baseUrl, `${endpoint}/${taskId}`); + const authConfig = applyAuthConfig(authMode, providerConfig, pollUrl, {}); + const pollResponse = await this.httpClient.request( + { + url: authConfig.url, + method: "GET", + headers: authConfig.headers, + }, + abortToken + ); + return pollResponse.data; + }, + (data): TaskPollResult => { + const statusRaw = data?.status || data?.data?.status; + if (statusRaw === "failed") { + return { status: "failed", errorMessage: "视频生成失败" }; + } + + const url = buildDoubaoVideoUrl(data); + if (url) { + return { status: "succeeded", result: url }; + } + + return { status: "pending" }; + }, + { + maxAttempts: 303, + intervalMs: 2000, + }, + token + ); + + return [{ url: videoUrl, mimeType: "video/mp4" }]; + } + + createAbortToken(): AbortToken { + const token = createAbortToken(); + this.activeTokens.add(token); + token.signal.addEventListener("abort", () => this.activeTokens.delete(token), { once: true }); + return token; + } + + releaseToken(token: AbortToken): void { + this.activeTokens.delete(token); + } + + cancelAllOperations(reason?: string): void { + const tokens = Array.from(this.activeTokens); + this.activeTokens.clear(); + for (const token of tokens) { + if (!token.aborted) token.abort(reason || "操作已取消"); + } + } + + async destroy(): Promise { + this.cancelAllOperations("服务已停止"); + if (this.configManager) this.configManager.unregisterListener(this); + } + + async callAI( + question: string, + images: AIContentPart[] = [], + token?: AbortToken + ): Promise<{ text: string; images: AIImage[] }> { + const { providerConfig, model, config } = await this.getCurrentProviderConfig("chat"); + const { modeConfig } = this.resolveMode(providerConfig, "chat", model); + const handler = this.strategyHandlers[modeConfig.strategy]?.chat; + if (!handler) throw new UserError("当前提供商不支持聊天"); + return await handler({ providerConfig, model, config, modeConfig, question, images, token }); + } + + async callSearch( + question: string, + images: AIContentPart[] = [], + token?: AbortToken + ): Promise<{ text: string; sources: Array<{ url: string; title?: string }> }> { + const { providerConfig, model, config } = await this.getCurrentProviderConfig("search"); + const { modeConfig } = this.resolveMode(providerConfig, "search", model); + const handler = this.strategyHandlers[modeConfig.strategy]?.search; + if (!handler) throw new UserError("当前提供商不支持搜索模式"); + return await handler({ providerConfig, model, config, modeConfig, question, images, token }); + } + + async generateImage(prompt: string, token?: AbortToken): Promise { + const { providerConfig, model, config } = await this.getCurrentProviderConfig("image"); + const { modeConfig } = this.resolveMode(providerConfig, "image", model); + const handler = this.strategyHandlers[modeConfig.strategy]?.image; + if (!handler) throw new UserError("当前提供商不支持图片生成"); + return await handler({ providerConfig, model, config, modeConfig, prompt, token }); + } + + async editImage(prompt: string, image: AIImage, token?: AbortToken): Promise { + const { providerConfig, model, config } = await this.getCurrentProviderConfig("image"); + const { modeConfig } = this.resolveMode(providerConfig, "image", model); + + if (!modeConfig.supportsEdit) { + throw new UserError("当前提供商未启用图片编辑支持"); + } + + if (!image.data) { + throw new Error("无法解析图片数据"); + } + + const handler = this.strategyHandlers[modeConfig.strategy]?.image; + if (!handler) throw new UserError("当前提供商不支持图片编辑"); + return await handler({ providerConfig, model, config, modeConfig, prompt, image, token }); + } + + async generateVideo( + prompt: string, + images: AIContentPart[], + imageMode: VideoImageMode = "auto", + token?: AbortToken + ): Promise { + const { providerConfig, model, config } = await this.getCurrentProviderConfig("video"); + const { modeConfig } = this.resolveMode(providerConfig, "video", model); + const handler = this.strategyHandlers[modeConfig.strategy]?.video; + if (!handler) throw new UserError("当前提供商不支持视频生成"); + return await handler({ providerConfig, model, config, modeConfig, prompt, images, imageMode, token }); + } +} + +abstract class BaseFeatureHandler implements FeatureHandler { + abstract readonly name: string; + abstract readonly command: string; + abstract readonly description: string; + abstract execute(msg: Api.Message, args: string[], prefixes: string[]): Promise; + + protected configManagerPromise: Promise; + + protected constructor(configManagerPromise: Promise) { + this.configManagerPromise = configManagerPromise; + } + + protected async getConfigManager(): Promise { + return await this.configManagerPromise; + } + + protected async getConfig(): Promise { + const configManager = await this.getConfigManager(); + return configManager.getConfig(); + } + + protected async editMessage(msg: Api.Message, text: string, parseMode: string = "html"): Promise { + await MessageSender.sendOrEdit(msg, text, { parseMode }); + } +} + +class ConfigFeature extends BaseFeatureHandler { + readonly name = "配置管理"; + readonly command = "config"; + readonly description = "管理API配置"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + const list = + Object.values(config.configs) + .map((c) => `🏷️ ${c.tag} - ${c.url}`) + .join("\n") || "暂无配置"; + await this.editMessage(msg, `📋 API配置列表:\n\n⚙️ 配置:\n${list}`); + return; + } + + const action = args[1].toLowerCase(); + if (action === "add") { + requireUser(args.length >= 5, "参数格式错误"); + await this.addConfig(msg, args, configManager); + return; + } + if (action === "del") { + requireUser(args.length >= 3, "参数格式错误"); + await this.deleteConfig(msg, args, configManager); + return; + } + throw new UserError("参数格式错误"); + } + + private async addConfig(msg: Api.Message, args: string[], configManager: ConfigManager): Promise { + requireUser(!!(msg as any).savedPeerId, "出于安全考虑,禁止在公开场景添加/修改API密钥"); + const key = args[args.length - 1]; + const url = args[args.length - 2]; + const tag = args.slice(2, -2).join(" ").trim(); + requireUser(!!tag, "参数格式错误"); + + try { + const u = new URL(url); + if (!["http:", "https:"].includes(u.protocol)) throw new Error("bad protocol"); + } catch { + throw new UserError("无效的URL格式"); + } + + requireUser(!!key.trim(), "API密钥不能为空"); + requireUser(key.length >= 10, "API密钥长度过短"); + + await configManager.updateConfig((cfg) => { + cfg.configs[tag] = { tag, url, key }; + }); + + await this.editMessage( + msg, + "✅ API配置已添加:\n\n" + + `🏷️ 标签: ${tag}\n` + + `🔗 地址: ${url}\n` + + `🔑 密钥: ${key}` + ); + } + + private async deleteConfig(msg: Api.Message, args: string[], configManager: ConfigManager): Promise { + const delTag = args[2]; + const config = configManager.getConfig(); + + requireUser(!!config.configs[delTag], "配置不存在"); + + await configManager.updateConfig((cfg) => { + delete cfg.configs[delTag]; + if (cfg.currentChatTag === delTag) { + cfg.currentChatTag = ""; + cfg.currentChatModel = ""; + } + if (cfg.currentImageTag === delTag) { + cfg.currentImageTag = ""; + cfg.currentImageModel = ""; + } + if (cfg.currentVideoTag === delTag) { + cfg.currentVideoTag = ""; + cfg.currentVideoModel = ""; + } + }); + + await this.editMessage(msg, `✅ 已删除配置: ${delTag}`); + } +} + +class ModelFeature extends BaseFeatureHandler { + readonly name = "模型管理"; + readonly command = "model"; + readonly description = "设置AI模型"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + await this.editMessage( + msg, + `🤖 当前AI配置:\n\n` + + `💬 chat配置: ${config.currentChatTag || "未设置"}\n` + + `🧠 chat模型: ${config.currentChatModel || "未设置"}\n` + + `🔎 search配置: ${config.currentSearchTag || "未设置"}\n` + + `📚 search模型: ${config.currentSearchModel || "未设置"}\n` + + `🖼️ image配置: ${config.currentImageTag || "未设置"}\n` + + `🎨 image模型: ${config.currentImageModel || "未设置"}\n` + + `🎬 video配置: ${config.currentVideoTag || "未设置"}\n` + + `📹 video模型: ${config.currentVideoModel || "未设置"}` + ); + return; + } + + const mode = args[1]?.toLowerCase(); + requireUser(mode === "chat" || mode === "search" || mode === "image" || mode === "video", "参数格式错误"); + requireUser(args.length >= 4, "参数不足"); + + const model = args[args.length - 1]; + const tag = args.slice(2, -1).join(" ").trim(); + requireUser(!!config.configs[tag], `配置标签 "${tag}" 不存在`); + + await configManager.updateConfig((cfg) => { + if (mode === "chat") { + cfg.currentChatTag = tag; + cfg.currentChatModel = model; + } else if (mode === "search") { + cfg.currentSearchTag = tag; + cfg.currentSearchModel = model; + } else if (mode === "video") { + cfg.currentVideoTag = tag; + cfg.currentVideoModel = model; + } else { + cfg.currentImageTag = tag; + cfg.currentImageModel = model; + } + }); + + const modeLabel = + mode === "chat" ? "chat模型" : mode === "search" ? "search模型" : mode === "image" ? "image模型" : "video模型"; + await this.editMessage( + msg, + `✅ ${modeLabel}已切换到:\n\n🏷️ 配置: ${tag}\n🧠 模型: ${model}` + ); + } +} + +class PromptFeature extends BaseFeatureHandler { + readonly name = "提示词管理"; + readonly command = "prompt"; + readonly description = "管理提示词"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + await this.editMessage(msg, `💭 当前提示词:\n\n📝 内容: ${config.prompt || "未设置"}`); + return; + } + + const action = args[1].toLowerCase(); + if (action === "set") { + requireUser(args.length >= 3, "参数格式错误"); + await configManager.updateConfig((cfg) => { + cfg.prompt = args.slice(2).join(" "); + }); + await this.editMessage(msg, `✅ 提示词已设置:\n\n${args.slice(2).join(" ")}`); + return; + } + + if (action === "del") { + await configManager.updateConfig((cfg) => { + cfg.prompt = ""; + }); + await this.editMessage(msg, "✅ 提示词已删除"); + return; + } + + throw new UserError("参数格式错误"); + } +} + +class CollapseFeature extends BaseFeatureHandler { + readonly name = "折叠设置"; + readonly command = "collapse"; + readonly description = "设置消息折叠"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + await this.editMessage( + msg, + `📖 消息折叠状态:\n\n📄 当前状态: ${config.collapse ? "开启" : "关闭"}` + ); + return; + } + + const state = args[1].toLowerCase(); + requireUser(state === "on" || state === "off", "参数必须是 on 或 off"); + + await configManager.updateConfig((cfg) => { + cfg.collapse = state === "on"; + }); + + await this.editMessage(msg, `✅ 引用折叠已${state === "on" ? "开启" : "关闭"}`); + } +} + +class TelegraphFeature extends BaseFeatureHandler { + readonly name = "Telegraph管理"; + readonly command = "telegraph"; + readonly description = "管理Telegraph"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + await this.showTelegraphStatus(msg, config); + return; + } + + const action = args[1].toLowerCase(); + if (action === "on") { + await this.enableTelegraph(msg, configManager); + return; + } + if (action === "off") { + await this.disableTelegraph(msg, configManager); + return; + } + if (action === "limit") { + requireUser(args.length >= 3, "参数格式错误"); + await this.setTelegraphLimit(msg, args, configManager); + return; + } + if (action === "del") { + requireUser(args.length >= 3, "参数格式错误"); + await this.deleteTelegraphItem(msg, args, configManager); + return; + } + await this.showTelegraphStatus(msg, config); + } + + private async showTelegraphStatus(msg: Api.Message, config: DB): Promise { + let status = + `📰 Telegraph状态:\n\n` + + `🌐 当前状态: ${config.telegraph.enabled ? "开启" : "关闭"}\n` + + `📊 限制数量: ${config.telegraph.limit}\n` + + `📈 记录数量: ${config.telegraph.list.length}/${config.telegraph.limit}`; + + if (config.telegraph.list.length > 0) { + status += "\n\n"; + config.telegraph.list.forEach((item, index) => { + status += `${index + 1}. 🔗 ${item.title}\n`; + }); + } + + await this.editMessage(msg, status); + } + + private async enableTelegraph(msg: Api.Message, configManager: ConfigManager): Promise { + await configManager.updateConfig((cfg) => { + cfg.telegraph.enabled = true; + }); + await this.editMessage(msg, "✅ Telegraph已开启"); + } + + private async disableTelegraph(msg: Api.Message, configManager: ConfigManager): Promise { + await configManager.updateConfig((cfg) => { + cfg.telegraph.enabled = false; + }); + await this.editMessage(msg, "✅ Telegraph已关闭"); + } + + private async setTelegraphLimit(msg: Api.Message, args: string[], configManager: ConfigManager): Promise { + const limit = parseInt(args[2]); + requireUser(!isNaN(limit) && limit > 0, "限制数量必须大于0"); + + await configManager.updateConfig((cfg) => { + cfg.telegraph.limit = limit; + }); + + await this.editMessage(msg, `✅ Telegraph限制已设置为 ${limit}`); + } + + private async deleteTelegraphItem(msg: Api.Message, args: string[], configManager: ConfigManager): Promise { + const del = args[2]; + const config = configManager.getConfig(); + + if (del.toLowerCase() === "all") { + await configManager.updateConfig((cfg) => { + cfg.telegraph.list = []; + }); + await this.editMessage(msg, "✅ 已删除所有记录"); + return; + } + + const idx = parseInt(del) - 1; + requireUser( + !isNaN(idx) && idx >= 0 && idx < config.telegraph.list.length, + `序号超出范围 (1-${config.telegraph.list.length})` + ); + + await configManager.updateConfig((cfg) => { + cfg.telegraph.list.splice(idx, 1); + }); + + await this.editMessage(msg, `✅ 已删除第 ${idx + 1} 项`); + } +} + +class TimeoutFeature extends BaseFeatureHandler { + readonly name = "超时设置"; + readonly command = "timeout"; + readonly description = "设置请求超时"; + + constructor(configManagerPromise: Promise) { + super(configManagerPromise); + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + if (args.length < 2) { + await this.editMessage( + msg, + `⏱️ 当前超时设置:\n\n⏰ 超时时间: ${config.timeout} 秒` + ); + return; + } + + const timeout = parseInt(args[1]); + requireUser(!isNaN(timeout) && timeout >= 1 && timeout <= 600, "超时时间必须在1-600秒之间"); + + await configManager.updateConfig((cfg) => { + cfg.timeout = timeout; + }); + + await this.editMessage(msg, `✅ 超时时间已设置为 ${timeout} 秒`); + } +} + +class QuestionFeature extends BaseFeatureHandler { + readonly name = "AI提问"; + readonly command = ""; + readonly description = "向AI提问"; + + private aiService: AIService; + private messageUtils: MessageUtils; + private activeToken?: AbortToken; + + constructor(aiService: AIService, configManagerPromise: Promise, httpClient: HttpClient) { + super(configManagerPromise); + this.aiService = aiService; + this.messageUtils = new MessageUtils(configManagerPromise, httpClient); + } + + cancelCurrentOperation(): void { + if (this.activeToken && !this.activeToken.aborted) this.activeToken.abort("操作被取消"); + this.activeToken = undefined; + } + + private async runQuestion(msg: Api.Message, question: string, trigger?: Api.Message): Promise { + this.cancelCurrentOperation(); + + const token = this.aiService.createAbortToken(); + this.activeToken = token; + + try { + await this.handleQuestion(msg, question, trigger, token); + } finally { + this.activeToken = undefined; + this.aiService.releaseToken(token); + } + } + + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const question = args.join(" ").trim(); + await this.runQuestion(msg, question); + } + + async askFromReply(msg: Api.Message, trigger?: Api.Message): Promise { + const replyMsg = await msg.getReplyMessage(); + requireUser(!!replyMsg, "至少需要一条提示"); + const question = getMessageText(replyMsg).trim(); + await this.runQuestion(msg, question, trigger); + } + + async handleQuestion(msg: Api.Message, question: string, trigger?: Api.Message, token?: AbortToken): Promise { + const config = await this.getConfig(); + + if (!config.currentChatTag || !config.currentChatModel || !config.configs[config.currentChatTag]) { + const prefixes = getPrefixes(); + throw new UserError( + `请先配置API并设置模型\n使用 ${prefixes[0]}ai config add 和 ${prefixes[0]}ai model chat ` + ); + } + + token?.throwIfAborted(); + + await sendProcessing(msg, "chat"); + + const replyMsg = await msg.getReplyMessage(); + let context = getMessageText(replyMsg); + const replyToId = replyMsg?.id; + const imageParts = [...(await getMessageImageParts(replyMsg)), ...(await getMessageImageParts(msg))]; + + const normalizedQuestion = question.trim(); + const normalizedContext = context.trim(); + if (normalizedQuestion && normalizedContext && normalizedQuestion === normalizedContext) { + context = ""; + } -/* ---------- PCM -> WAV ---------- */ -const convertPcmL16ToWavIfNeeded = (raw: Buffer, mime?: string): { buf: Buffer; mime: string } => { - let buf = raw; - let outMime = mime || "audio/ogg"; - const lm = outMime.toLowerCase(); - if (lm.includes("l16") && lm.includes("pcm")) { - try { - const parse = (mt: string) => { - const [fileType, ...params] = mt.split(";").map(s => s.trim()); - const [, format] = (fileType || "").split("/"); - const opts: any = { numChannels: 1, sampleRate: 24000, bitsPerSample: 16 }; - if (format && format.toUpperCase().startsWith("L")) { - const bits = parseInt(format.slice(1), 10); - if (!isNaN(bits)) opts.bitsPerSample = bits; - } - for (const param of params) { - const [k, v] = param.split("=").map(s => s.trim()); - if (k === "rate") { const r = parseInt(v, 10); if (!isNaN(r)) opts.sampleRate = r; } - if (k === "channels") { const c = parseInt(v, 10); if (!isNaN(c)) opts.numChannels = c; } - } - return opts; - }; - const createHeader = (len: number, o: any) => { - const byteRate = o.sampleRate * o.numChannels * o.bitsPerSample / 8; - const blockAlign = o.numChannels * o.bitsPerSample / 8; - const b = Buffer.alloc(44); - b.write("RIFF", 0); - b.writeUInt32LE(36 + len, 4); - b.write("WAVE", 8); - b.write("fmt ", 12); - b.writeUInt32LE(16, 16); - b.writeUInt16LE(1, 20); - b.writeUInt16LE(o.numChannels, 22); - b.writeUInt32LE(o.sampleRate, 24); - b.writeUInt32LE(byteRate, 28); - b.writeUInt16LE(blockAlign, 32); - b.writeUInt16LE(o.bitsPerSample, 34); - b.write("data", 36); - b.writeUInt32LE(len, 40); - return b; - }; - const opts = parse(outMime); - const header = createHeader(buf.length, opts); - buf = Buffer.concat([header, buf]); - outMime = "audio/wav"; - } catch { } - } - return { buf, mime: outMime }; -}; + const userText = context ? `上下文:\n${context}\n\n问题:\n${question}` : question; -/* ---------- 语音发送 ---------- */ -const sendVoiceWithCaption = async (msg: Api.Message, fileBuf: Buffer, caption: string, replyToId?: number): Promise => { - try { - const file: any = Object.assign(fileBuf, { name: "ai.ogg" }); - await msg.client?.sendFile(msg.peerId, { - file, - caption, - parseMode: "html", - replyTo: replyToId || undefined, - attributes: [new Api.DocumentAttributeAudio({ duration: 0, voice: true })] - }); - } catch (error: any) { - if (error?.message?.includes("CHAT_SEND_VOICES_FORBIDDEN") || error?.message?.includes("VOICES_FORBIDDEN")) { - try { - const altFile: any = Object.assign(fileBuf, { name: "ai.wav" }); - await msg.client?.sendFile(msg.peerId, { - file: altFile, - caption, - parseMode: "html", - replyTo: replyToId || undefined - }); - return; - } catch { - // 回退到文本消息 - const fallbackText = caption + "\n\n⚠️ 语音发送被禁止,已转为文本消息"; - if (replyToId) { - await msg.client?.sendMessage(msg.peerId, { message: fallbackText, parseMode: "html", replyTo: replyToId }); - } else { - await msg.client?.sendMessage(msg.peerId, { message: fallbackText, parseMode: "html" }); - } - } + const response = await this.aiService.callAI(userText, imageParts, token); + const answer = response.text || "AI回复为空"; + + const collapseSafe = config.collapse; + const htmlAnswer = TelegramFormatter.markdownToHtml(answer, { collapseSafe }); + const safeQuestion = htmlEscape(question); + const formattedAnswer = `Q:\n${safeQuestion}\n\nA:\n${htmlAnswer}`; + + token?.throwIfAborted(); + + if (config.telegraph.enabled && formattedAnswer.length > 4050) { + await this.handleLongContentWithTelegraph(msg, question, answer, replyToId, token); } else { - throw error; + await this.messageUtils.sendLongMessage(msg, formattedAnswer, replyToId, token, { + poweredByTag: config.currentChatTag, + }); } + await deleteMessageOrGroup(msg); } -}; -/* ---------- 图片发送 ---------- */ -const sendImageFile = async (msg: Api.Message, buf: Buffer, caption: string, replyToId?: number, mimeHint?: string): Promise => { - const ext = (mimeHint || "image/png").includes("png") ? "png" : (mimeHint || "").includes("jpeg") ? "jpg" : "png"; - const file: any = Object.assign(buf, { name: `ai.${ext}` }); - await msg.client?.sendFile(msg.peerId, { file, caption, parseMode: "html", replyTo: replyToId || undefined }); -}; + private async handleLongContentWithTelegraph( + msg: Api.Message, + question: string, + rawAnswer: string, + replyToId?: number, + token?: AbortToken + ): Promise { + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + + const telegraphMarkdown = `**Q:**\n${question}\n\n**A:**\n${rawAnswer}\n`; + const telegraphResult = await this.messageUtils.createTelegraphPage(telegraphMarkdown, question, token); + + const poweredByText = `\n🍀Powered by ${config.currentChatTag}`; + const safeQuestion = htmlEscape(question); + const questionBlock = config.collapse + ? `Q:\n
${safeQuestion}
\n` + : `Q:\n${safeQuestion}\n`; + const answerBlock = config.collapse + ? `A:\n
📰内容比较长,Telegraph观感更好喔:\n🔗 点我阅读内容
${poweredByText}` + : `A:\n📰内容比较长,Telegraph观感更好喔:\n🔗 点我阅读内容${poweredByText}`; + + await MessageSender.sendNew(msg, questionBlock + answerBlock, { parseMode: "html", linkPreview: false }, replyToId); + + await configManager.updateConfig((cfg) => { + cfg.telegraph.list.push(telegraphResult); + if (cfg.telegraph.list.length > cfg.telegraph.limit) cfg.telegraph.list.shift(); + }); + } +} -/* ---------- 长文自动选择 ---------- */ -const sendLongAuto = async (msg: Api.Message, text: string, replyToId?: number, opts?: { collapse?: boolean }, postfix?: string): Promise => { - if (replyToId) await sendLongReply(msg, replyToId, text, opts, postfix); - else await sendLong(msg, text, opts, postfix); -}; +class SearchFeature extends BaseFeatureHandler { + readonly name = "联网搜索"; + readonly command = "search"; + readonly description = "使用联网能力搜索并回答"; -// 公共 TTS 执行函数 -const executeTTS = async (msg: Api.Message, text: string, replyToId: number): Promise => { - const m = pick("tts"); - if (!m) { await msg.edit({ text: "❌ 未设置 tts 模型", parseMode: "html" }); return false; } - const p = providerOf(m.provider); - if (!p?.apiKey) { await msg.edit({ text: "❌ 服务商/令牌未配置", parseMode: "html" }); return false; } - const compat = await resolveCompat(m.provider, m.model, p); - if (!Store.data.voices) Store.data.voices = { gemini: "Kore", openai: "alloy" }; - const voice = compat === "gemini" ? Store.data.voices.gemini : Store.data.voices.openai; - await msg.edit({ text: "🔊 合成中...", parseMode: "html" }); - try { - if (compat === "openai") { - const audio = await ttsOpenAI(p, m.model, text, voice); - await sendVoiceWithCaption(msg, audio, "", replyToId); - } else if (compat === "gemini") { - const { audio, mime } = await ttsGemini(p, m.model, text, voice); - if (!audio) { await msg.edit({ text: "❌ 语音合成失败", parseMode: "html" }); return false; } - const { buf } = convertPcmL16ToWavIfNeeded(audio, mime); - await sendVoiceWithCaption(msg, buf, "", replyToId); - } else { - await msg.edit({ text: "❌ 当前服务商不支持语音合成", parseMode: "html" }); return false; - } - await msg.delete(); - return true; - } catch (e: any) { - await msg.edit({ text: `❌ 语音合成失败: ${html(e?.message || e)}`, parseMode: "html" }); - return false; + private aiService: AIService; + private messageUtils: MessageUtils; + + constructor(aiService: AIService, configManagerPromise: Promise, httpClient: HttpClient) { + super(configManagerPromise); + this.aiService = aiService; + this.messageUtils = new MessageUtils(configManagerPromise, httpClient); } -}; + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const prefixes = getPrefixes(); + const config = await this.getConfig(); -/* ---------- 模型列表解析 ---------- */ -const parseModelListFromResponse = (data: any): string[] => { - const arr = Array.isArray(data) ? data : data?.data || data?.models || []; - return (arr || []).map((x: any) => normalizeModelName(x)); -}; + const promptInput = args.slice(1).join(" ").trim(); -/* ---------- 按兼容类型枚举模型 ---------- */ -const listModels = async (p: Provider, compat: Compat): Promise => { - const base = trimBase(p.baseUrl); - const tryGet = async (url: string, headers: Record = {}, prefer?: Compat) => { - const attempts = buildAuthAttempts({ ...p, compatauth: prefer || p.compatauth } as Provider, headers); - let lastErr: any; - for (const a of attempts) { - try { - const r = await axiosWithRetry({ method: "GET", url, ...(a || {}) }); - return r.data; - } catch (e: any) { - lastErr = e; - } - } - throw lastErr; - }; - let lastErr: any = null; - if (compat === "openai") { - const url = base + "/v1/models"; - try { - const data = await tryGet(url); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - try { - const vAnth = await getAnthropicVersion(p); - const data = await tryGet(url, { "anthropic-version": vAnth }, "claude"); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - try { - const data = await tryGet(base + "/v1beta/models", {}, "gemini"); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - } else if (compat === "claude") { - const url = base + "/v1/models"; - try { - const vAnth = await getAnthropicVersion(p); - const data = await tryGet(url, { "anthropic-version": vAnth }, "claude"); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - try { - const data = await tryGet(url); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - try { - const data = await tryGet(base + "/v1beta/models", {}, "gemini"); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; - } - } else { - const url1 = base + "/v1beta/models"; - const url2 = base + "/v1/models"; - try { - const data = await tryGet(url1, {}, "gemini"); - const list = parseModelListFromResponse(data); - if (list.length) return list; - } catch (e: any) { - lastErr = e; - } - try { - const data = await tryGet(url2, {}, "gemini"); - const list = parseModelListFromResponse(data); - if (list.length) return list; - } catch (e: any) { - lastErr = e; + const replyMsg = await msg.getReplyMessage(); + requireUser(!!promptInput || !!replyMsg, "至少需要一条提示"); + + if (!config.currentSearchTag || !config.currentSearchModel || !config.configs[config.currentSearchTag]) { + throw new UserError( + `请先配置API并设置模型\n使用 ${prefixes[0]}ai config add 和 ${prefixes[0]}ai model search ` + ); } - try { - const data = await tryGet(url2); - return parseModelListFromResponse(data); - } catch (e: any) { - lastErr = e; + + await sendProcessing(msg, "search"); + + const replyToId = replyMsg?.id; + + let context = getMessageText(replyMsg); + const imageParts = [...(await getMessageImageParts(replyMsg)), ...(await getMessageImageParts(msg))]; + + const normalizedPrompt = promptInput.trim(); + const normalizedContext = (context || "").trim(); + + if (normalizedPrompt && normalizedContext && normalizedPrompt === normalizedContext) { + context = ""; } - } - if (lastErr) throw lastErr; - throw new Error("无法获取模型列表:服务无有效输出"); -}; -const listModelsByAnyCompat = async (p: Provider): Promise<{ models: string[]; compat: Compat | null; compats: Compat[]; modelMap?: Record }> => { - const order: Compat[] = ["openai", "gemini", "claude"]; - const merged = new Map(); - const compats: Compat[] = []; - const modelMap: Record = {}; - let primary: Compat | null = null; - for (const c of order) { + + const userText = context ? `上下文:\n${context}\n\n问题:\n${promptInput}` : promptInput; + + const token = this.aiService.createAbortToken(); try { - const list = await listModels(p, c); - if (Array.isArray(list) && list.length) { - if (!primary) primary = c; - if (!compats.includes(c)) compats.push(c); - for (const m of list) { - const k = String(m || "").toLowerCase(); - if (k && !merged.has(k)) merged.set(k, m); - if (k && modelMap[k] === undefined) modelMap[k] = c; - } + const { text, sources } = await this.aiService.callSearch(userText, imageParts, token); + + const sourcesText = + sources && sources.length > 0 + ? "\n\n🔗 Sources\n" + + sources + .slice(0, 8) + .map((s, i) => { + const safeUrl = htmlEscape(s.url); + const safeTitle = htmlEscape(s.title || s.url); + return `${i + 1}. ${safeTitle}`; + }) + .join("\n") + : ""; + + const collapseSafe = config.collapse; + const htmlAnswer = TelegramFormatter.markdownToHtml(text || "AI回复为空", { collapseSafe }); + + const safeQuestion = htmlEscape(promptInput); + const formatted = `Q:\n${safeQuestion}\n\nA:\n${htmlAnswer}${sourcesText}`; + + if (config.telegraph.enabled && formatted.length > 4050) { + const telegraphMarkdown = + `**Q:**\n${promptInput}\n\n**A:**\n${text || "AI回复为空"}\n\n` + + (sources && sources.length + ? `**Sources:**\n` + sources.slice(0, 20).map((s, i) => `${i + 1}. ${s.title || s.url}\n${s.url}`).join("\n") + : ""); + + const telegraphResult = await this.messageUtils.createTelegraphPage(telegraphMarkdown, promptInput, token); + + const poweredByText = `\n🍀Powered by ${config.currentSearchTag}`; + const qBlock = config.collapse + ? `Q:\n
${safeQuestion}
\n` + : `Q:\n${safeQuestion}\n`; + const aBlock = config.collapse + ? `A:\n
📰内容较长,Telegraph 观感更好:\n🔗 点我阅读内容
${poweredByText}` + : `A:\n📰内容较长,Telegraph 观感更好:\n🔗 点我阅读内容${poweredByText}`; + + await MessageSender.sendNew(msg, qBlock + aBlock, { parseMode: "html", linkPreview: false }, replyToId); + + const configManager = await this.getConfigManager(); + await configManager.updateConfig((cfg) => { + cfg.telegraph.list.push(telegraphResult); + if (cfg.telegraph.list.length > cfg.telegraph.limit) cfg.telegraph.list.shift(); + }); + } else { + await this.messageUtils.sendLongMessage(msg, formatted, replyToId, token, { + poweredByTag: config.currentSearchTag, + }); } - } catch { } - } - for (const k of Object.keys(modelMap)) { - const g = detectCompat(k); - if ((g === "gemini" || g === "claude") && modelMap[k] !== g) modelMap[k] = g; - } - return { models: Array.from(merged.values()), compat: primary, compats, modelMap }; -}; -/* ---------- 预设 Prompt 应用 ---------- */ -const applyPresetPrompt = (userInput: string): string => { - const preset = Store.data.presetPrompt || ""; - if (!preset.trim()) return userInput; - return `${preset}\n\n${userInput}`; -}; + await deleteMessageOrGroup(msg); + } finally { + this.aiService.releaseToken(token); + } + } +} -/* ---------- 统一聊天调用 ---------- */ -const callChat = async (kind: "chat" | "search", text: string, msg: Api.Message): Promise<{ content: string; model: string }> => { - const m = pick(kind); - if (!m) throw new Error(`未设置${kind}模型,请先配置`); - const p = providerOf(m.provider); - if (!p) throw new Error(`服务商 ${m.provider} 未配置`); - const compat = await resolveCompat(m.provider, m.model, p); - const id = chatIdStr(msg); - const msgs: { role: string; content: string }[] = []; - const processedText = applyPresetPrompt(text); - msgs.push({ role: "user", content: processedText }); - let out = ""; - try { - const isSearch = kind === "search"; - if (compat === "openai") out = await chatOpenAI(p, m.model, msgs, undefined, isSearch); - else if (compat === "claude") out = await chatClaude(p, m.model, msgs, undefined, isSearch); - else out = await chatGemini(p, m.model, msgs, isSearch); - } catch (e: any) { - const em = e?.message || String(e); - throw new Error(`[${kind}] provider=${m.provider} compat=${compat} model=${html(m.model)} :: ${em}`); - } - if (Store.data.contextEnabled) { - pushHist(id, "user", text); - pushHist(id, "assistant", out); - await Store.writeSoon(); - } - return { content: out, model: m.model }; -}; +class ImageFeature extends BaseFeatureHandler { + readonly name = "图片生成"; + readonly command = "image"; + readonly description = "生成图片"; + private aiService: AIService; + private messageUtils: MessageUtils; + private httpClient: HttpClient; + constructor(aiService: AIService, configManagerPromise: Promise, httpClient: HttpClient) { + super(configManagerPromise); + this.aiService = aiService; + this.messageUtils = new MessageUtils(configManagerPromise, httpClient); + this.httpClient = httpClient; + } -/* ---------- 帮助文案 ---------- */ -const help_text = `🔧 📝 特性 -兼容 Google Gemini、OpenAI、Anthropic Claude、Baidu 标准接口,统一指令,一处配置,多处可用。 - -✨ 亮点 -• 🔀 模型混用:对话 / 搜索 / 图片 / 语音 可分别指定不同服务商的不同模型 -• 🧠 可选上下文记忆、📰 长文自动发布 Telegraph、🧾 消息折叠显示 -• 🎯 全局Prompt预设:为所有对话设置统一的系统提示词 - -
💬 对话 -${mainPrefix}ai chat [问题] -• 示例:${mainPrefix}ai chat 你好,帮我简单介绍一下你 -• 支持多轮对话(可执行 ${mainPrefix}ai context on 开启记忆) -• 超长回答可自动转 Telegraph - -🔍 搜索 -${mainPrefix}ai search [查询] -• 示例:${mainPrefix}ai search 2024 年 AI 技术进展 - -🖼️ 图片 -${mainPrefix}ai image [描述] -• 示例:${mainPrefix}ai image 未来城市的科幻夜景 - -🎵 文本转语音 -${mainPrefix}ai tts [文本] -• 示例:${mainPrefix}ai tts 你好,这是一次语音合成测试 - -🎤 语音回答 -${mainPrefix}ai audio [问题] -• 示例:${mainPrefix}ai audio 用 30 秒介绍人工智能的发展 - -🔍🎤 搜索并语音回答 -${mainPrefix}ai searchaudio [查询] -• 示例:${mainPrefix}ai searchaudio 2024 年最新科技趋势 - -🎯 全局Prompt预设 -${mainPrefix}ai prompt set [内容] - 设置全局Prompt预设 -${mainPrefix}ai prompt clear - 清除全局Prompt预设 -${mainPrefix}ai prompt show - 显示当前Prompt预设 -• 预设将自动添加到所有对话请求前,适用于角色设定、回答风格等统一配置 - -💭 对话上下文 -${mainPrefix}ai context on|off|show|del - -📋 消息折叠 -${mainPrefix}ai collapse on|off - -📰 Telegraph 长文 -${mainPrefix}ai telegraph on|off|limit <数量>|list|del <n|all> -• limit <数量>:设置字数阈值(0 表示不限制) -• 自动创建 / 管理 / 删除 Telegraph 文章 - -🎤 音色管理 -${mainPrefix}ai voice list - 查看所有可用音色(Gemini 30种 / OpenAI 6种) -${mainPrefix}ai voice show - 查看当前音色配置 -${mainPrefix}ai voice gemini [音色名] - 设置 Gemini TTS 音色 -${mainPrefix}ai voice openai [音色名] - 设置 OpenAI TTS 音色 -• Gemini 音色示例:Kore, Puck, Charon, Leda, Aoede 等 -• OpenAI 音色示例:alloy, echo, fable, onyx, nova, shimmer - -⚙️ 模型管理 -${mainPrefix}ai model list - 查看当前模型配置 -${mainPrefix}ai model chat|search|image|tts [服务商] [模型] - 设置各功能模型 -${mainPrefix}ai model default - 清空所有功能模型 -${mainPrefix}ai model auto - 智能分配 chat/search/image/tts - -🔧 配置管理 -${mainPrefix}ai config status - 显示配置概览 -${mainPrefix}ai config add [服务商] [API密钥] [BaseURL] -${mainPrefix}ai config list - 查看已配置的服务商 -${mainPrefix}ai config model [服务商] - 查看该服务商可用模型 -${mainPrefix}ai config update [服务商] [apikey|baseurl] [值] -${mainPrefix}ai config remove [服务商|all] - -📝 配置示例 -• OpenAI:${mainPrefix}ai config add openai sk-proj-xxx https://api.openai.com -• DeepSeek:${mainPrefix}ai config add deepseek sk-xxx https://api.deepseek.com -• Grok:${mainPrefix}ai config add grok xai-xxx https://api.x.ai -• Claude:${mainPrefix}ai config add claude sk-ant-xxx https://api.anthropic.com -• Gemini:${mainPrefix}ai config add gemini AIzaSy-xxx https://generativelanguage.googleapis.com - -⚡ 简洁命令与别名 -常用简写 -• 对话:${mainPrefix}ai [问题]${mainPrefix}ai chat [问题] -• 搜索:${mainPrefix}ai s [查询] -• 图片:${mainPrefix}ai img [描述] -• 语音:${mainPrefix}ai v [文本] -• 回答为语音:${mainPrefix}ai a [问题] / 搜索并语音:${mainPrefix}ai sa [查询] -• 上下文:${mainPrefix}ai ctx on|off -• 模型:${mainPrefix}ai m list / 设置:${mainPrefix}ai m chat|search|image|tts [服务商] [模型] -• 配置:${mainPrefix}ai c add [服务商] [API密钥] [BaseURL] -• 别名:s=search, img/i=image, v=tts, a=audio, sa=searchaudio, ctx=context, fold=collapse, cfg/c=config, m=model - -⏱️ 超时设置 -${mainPrefix}ai timeout - 查看当前超时时间 -${mainPrefix}ai timeout set [秒] - 设置超时时间(10-600秒) -${mainPrefix}ai timeout reset - 重置为默认值(30秒) - -📝 最大输出 Token -${mainPrefix}ai maxtokens - 查看当前设置 -${mainPrefix}ai maxtokens set [数量] - 设置最大输出 token(100-128000) -${mainPrefix}ai maxtokens reset - 重置为默认值(16384,约8000中文字) -• 生成超长文本时需增大此值,同时建议增加超时时间 - -🔗 链接预览 -${mainPrefix}ai preview - 查看当前状态 -${mainPrefix}ai preview on|off - 开启/关闭链接预览 -
`; - - -/* ---------- 插件主体 ---------- */ -class AiPlugin extends Plugin { - description = `🤖 智能AI助手\n\n${help_text}`; - cmdHandlers = { - ai: async (msg: Api.Message) => { - await Store.init(); - ensureDir(); - const text = (msg as any).text || (msg as any).message || ""; - const lines = text.trim().split(/\r?\n/g); - const parts = (lines[0] || "").split(/\s+/); - const [, sub, ...args] = parts; - const subl = (sub || "").toLowerCase(); - const aliasMap: Record = { - s: "search", - img: "image", - i: "image", - v: "tts", - a: "audio", - sa: "searchaudio", - ctx: "context", - fold: "collapse", - cfg: "config", - c: "config", - m: "model" - }; - const subn = aliasMap[subl] || subl; - const knownSubs = [ - "config", "model", "context", "collapse", "telegraph", "voice", "prompt", - "chat", "search", "image", "tts", "audio", "searchaudio", "help", "timeout", "preview", "maxtokens" - ]; - const isUnknownBareQuery = !!subn && !knownSubs.includes(subn); - try { - const preflight = async (kind: keyof Models): Promise<{ m: { provider: string; model: string }; p: Provider; compat: Compat } | null> => { - const m = pick(kind); - if (!m) { await msg.edit({ text: `❌ 未设置 ${kind} 模型`, parseMode: "html" }); return null; } - const p = providerOf(m.provider); - if (!p) { await msg.edit({ text: "❌ 服务商未配置", parseMode: "html" }); return null; } - if (!p.apiKey) { await msg.edit({ text: "❌ 未提供令牌,请先配置 API Key(ai config add/update)", parseMode: "html" }); return null; } - const compat = await resolveCompat(m.provider, m.model, p); - return { m, p, compat }; - }; + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const prefixes = getPrefixes(); + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + const replyMsg = await msg.getReplyMessage(); + const replyToId = replyMsg?.id; + + const subCommand = args[1]?.toLowerCase(); + if (subCommand === "preview") { + const state = args[2]?.toLowerCase(); + if (!state) { + await this.editMessage( + msg, + `🖼️ 图片预览状态:\n\n📄 当前状态: ${config.imagePreview ? "开启" : "关闭"}` + ); + return; + } + requireUser(state === "on" || state === "off", "参数必须是 on 或 off"); + await configManager.updateConfig((cfg) => { + cfg.imagePreview = state === "on"; + }); + await this.editMessage(msg, `✅ 图片预览已${state === "on" ? "开启" : "关闭"}`); + return; + } - /* ---------- 帮助命令 ---------- */ - if (subn === "help" || subn === "h" || subn === "?") { - await sendLong(msg, help_text); - return; - } + const promptInput = args.slice(1).join(" ").trim(); + const replyText = getMessageText(replyMsg).trim(); + const replyImageParts = await getMessageImageParts(replyMsg); + const messageImageParts = await getMessageImageParts(msg); + const imageParts = [...replyImageParts, ...messageImageParts]; - /* ---------- Prompt 预设管理 ---------- */ - if (subn === "prompt") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "set") { - // 支持多行 Prompt:从原始文本中提取 "prompt set" 后的全部内容 - const fullText = (msg as any).text || (msg as any).message || ""; - // 匹配 "-ai prompt set " 或 ".ai prompt set " 等前缀,忽略大小写 - const promptSetMatch = fullText.match(/^[.\-\/!]ai\s+prompt\s+set\s+/i); - let promptContent = ""; - if (promptSetMatch) { - promptContent = fullText.slice(promptSetMatch[0].length).trim(); - } else { - // 回退到旧逻辑(理论上不会走到这里) - promptContent = args.slice(1).join(" ").trim(); - } - if (!promptContent) { - await msg.edit({ text: "❌ 请提供预设Prompt内容", parseMode: "html" }); - return; - } - Store.data.presetPrompt = promptContent; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已设置全局Prompt预设\n\n
${html(promptContent)}
`, parseMode: "html" }); - return; - } - if (a0 === "clear") { - Store.data.presetPrompt = ""; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已清除全局Prompt预设", parseMode: "html" }); - return; - } - if (a0 === "show") { - const currentPrompt = Store.data.presetPrompt || ""; - if (!currentPrompt) { - await msg.edit({ text: "📝 当前未设置全局Prompt预设", parseMode: "html" }); - return; - } - await sendLong(msg, `📝 当前全局Prompt预设\n\n
${html(currentPrompt)}
`); - return; - } - await msg.edit({ text: "❌ 未知 prompt 子命令\n支持: set|clear|show", parseMode: "html" }); - return; - } + const hasPrompt = !!promptInput || !!replyText; + requireUser(hasPrompt, "至少需要一条文字提示"); - /* ---------- 配置管理 ---------- */ - if (subn === "config") { - if (isGroupOrChannel(msg)) { - await msg.edit({ text: "❌ 为保护用户隐私,禁止在公共对话环境使用ai config所有子命令", parseMode: "html" }); - return; - } - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "status") { - const cur = Store.data.models; - const flags = [ - `• 上下文: ${Store.data.contextEnabled ? "开启" : "关闭"}`, - `• 折叠: ${Store.data.collapse ? "开启" : "关闭"}`, - `• Telegraph: ${Store.data.telegraph.enabled ? "开启" : "关闭"}${Store.data.telegraph.enabled && Store.data.telegraph.limit ? `(阈值 ${Store.data.telegraph.limit})` : ""}`, - `• Prompt预设: ${Store.data.presetPrompt ? "✅ 已设置" : "❌ 未设置"}`, - - ].join("\n"); - const provList = Object.entries(Store.data.providers) - .map(([n, v]) => { - const display = shortenUrlForDisplay(v.baseUrl); - return `• ${html(n)} - key:${v.apiKey ? "✅" : "❌"} base:${html(display)}`; - }) - .join("\n") || "(空)"; - const txt = `⚙️ AI 配置概览\n\n功能模型\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}\n\n功能开关\n${flags}\n\n服务商\n${provList}`; - await sendLong(msg, txt); - return; - } - if (a0 === "add") { - const [name, key, baseUrl] = [args[1], args[2], args[3]]; - if (!name || !key || !baseUrl) { - await msg.edit({ text: "❌ 参数不足", parseMode: "html" }); - return; - } - try { - const u = new URL(baseUrl); - if (u.protocol !== "http:" && u.protocol !== "https:") { - await msg.edit({ text: "❌ baseUrl 无效,请使用 http/https 协议", parseMode: "html" }); - return; - } - } catch { - await msg.edit({ text: "❌ baseUrl 无效,请检查是否为合法 URL", parseMode: "html" }); - return; - } - Store.data.providers[name] = { apiKey: key, baseUrl: trimBase(baseUrl.trim()) }; - if (Store.data.modelCompat) delete Store.data.modelCompat[name]; - compatResolving.delete(name); - await Store.writeSoon(); - debouncedRefreshModelCatalog(); - await msg.edit({ text: `✅ 已添加 ${html(name)}`, parseMode: "html" }); - return; - } - if (a0 === "update") { - const [name, field, ...rest] = args.slice(1); - const value = (rest.join(" ") || "").trim(); - if (!name || !field || !value) { - await msg.edit({ text: "❌ 参数不足", parseMode: "html" }); - return; - } - const p = Store.data.providers[name]; - if (!p) { - await msg.edit({ text: "❌ 未找到服务商", parseMode: "html" }); - return; - } - if (field.toLowerCase() === "apikey") { - p.apiKey = value; - delete (p as any).compatauth; - } else if (field.toLowerCase() === "baseurl") { - try { - const u = new URL(value); - if (u.protocol !== "http:" && u.protocol !== "https:") { - await msg.edit({ text: "❌ baseUrl 无效,请使用 http/https 协议", parseMode: "html" }); - return; - } - } catch { - await msg.edit({ text: "❌ baseUrl 无效,请检查是否为合法 URL", parseMode: "html" }); - return; - } - p.baseUrl = trimBase(value.trim()); - delete (p as any).compatauth; - } else { - await msg.edit({ text: "❌ 字段仅支持 apikey|baseurl", parseMode: "html" }); - return; - } - if (Store.data.modelCompat) delete Store.data.modelCompat[name]; - compatResolving.delete(name); - await Store.writeSoon(); - debouncedRefreshModelCatalog(); - await msg.edit({ text: `✅ 已更新 ${html(name)}${html(field)}`, parseMode: "html" }); - return; - } - if (a0 === "remove") { - const target = (args[1] || "").toLowerCase(); - if (!target) { - await msg.edit({ text: "❌ 请输入服务商名称或 all", parseMode: "html" }); - return; - } - if (target === "all") { - Store.data.providers = {}; - Store.data.modelCompat = {}; - Store.data.modelCatalog = { map: {}, updatedAt: undefined } as any; - compatResolving.clear(); - } else { - if (!Store.data.providers[target]) { - await msg.edit({ text: "❌ 未找到服务商", parseMode: "html" }); - return; - } - delete Store.data.providers[target]; - if (Store.data.modelCompat) delete Store.data.modelCompat[target]; - const kinds: (keyof Models)[] = ["chat", "search", "image", "tts"]; - for (const k of kinds) { - const v = Store.data.models[k]; - if (v && v.startsWith(target + " ")) Store.data.models[k] = ""; - } - } - await Store.writeSoon(); - debouncedRefreshModelCatalog(); - await msg.edit({ text: "✅ 已删除", parseMode: "html" }); - return; - } - if (a0 === "list") { - const list = Object.entries(Store.data.providers) - .map(([n, v]) => { - const display = shortenUrlForDisplay(v.baseUrl); - return `• ${html(n)} - key:${v.apiKey ? "✅" : "❌"} base:${html(display)}`; - }) - .join("\n") || "(空)"; - await sendLong(msg, `📦 已配置服务商\n\n${list}`); - return; - } - if (a0 === "model") { - const name = args[1]; - const p = name && providerOf(name); - if (!p) { - await msg.edit({ text: "❌ 未找到服务商", parseMode: "html" }); - return; - } - let models: string[] = []; - let selected: Compat | null = null; - try { - const res = await listModelsByAnyCompat(p); - models = res.models; - selected = res.compat; - } catch { } - if (!models.length || !selected) { - await msg.edit({ text: "❌ 该服务商的权鉴方式未使用OpenAI、Google Gemini、Claude的标准接口,不做兼容。", parseMode: "html" }); - return; - } - const buckets = { chat: [] as string[], search: [] as string[], image: [] as string[], tts: [] as string[] }; - for (const m of models) { - const ml = String(m).toLowerCase(); - if (/image|dall|sd|gpt-image/.test(ml)) buckets.image.push(m); - else if (/tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml)) buckets.tts.push(m); - else { - buckets.chat.push(m); - buckets.search.push(m); - } - } - const txt = `🧾 ${html(name!)} 可用模型\n\nchat/search:\n${buckets.chat.length ? buckets.chat.map(x => "• " + html(x)).join("\n") : "(空)"}\n\nimage:\n${buckets.image.length ? buckets.image.map(x => "• " + html(x)).join("\n") : "(空)"}\n\ntts:\n${buckets.tts.length ? buckets.tts.map(x => "• " + html(x)).join("\n") : "(空)"}`; - await sendLong(msg, txt); - return; - } - await msg.edit({ text: "❌ 未知 config 子命令", parseMode: "html" }); - return; - } + if (!config.currentImageTag || !config.currentImageModel || !config.configs[config.currentImageTag]) { + throw new UserError( + `请先配置API并设置模型\n使用 ${prefixes[0]}ai config add 和 ${prefixes[0]}ai model image ` + ); + } - /* ---------- 模型管理 ---------- */ - if (subn === "model") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "list") { - const cur = Store.data.models; - const txt = `⚙️ 当前模型配置\n\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}`; - await sendLong(msg, txt); - return; - } - if (a0 === "default") { - Store.data.models = { chat: "", search: "", image: "", tts: "" }; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已清空所有功能模型设置", parseMode: "html" }); - return; - } - if (a0 === "auto") { - const entries = Object.entries(Store.data.providers); - if (!entries.length) { - await msg.edit({ text: "❌ 请先使用 ai config add 添加服务商", parseMode: "html" }); - return; - } - const modelsBy: Record = {}; - for (const [n, p] of entries) { - try { - const { models } = await listModelsByAnyCompat(p); - if (Array.isArray(models) && models.length) { - modelsBy[n] = models; - } else { - modelsBy[n] = []; - } - } catch { - modelsBy[n] = []; - } - } - const bucketsBy: Record = {}; - for (const [n, list] of Object.entries(modelsBy)) { - const buckets = { chat: [] as string[], search: [] as string[], image: [] as string[], tts: [] as string[] }; - for (const m of list) { - const ml = String(m).toLowerCase(); - if (/image|dall|sd|gpt-image/.test(ml)) buckets.image.push(m); - else if (/tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml)) buckets.tts.push(m); - else { - buckets.chat.push(m); - buckets.search.push(m); - } - } - bucketsBy[n] = buckets; - } - const orders: Array = ["openai", "gemini", "claude", "other"]; - const modelFamilyOf = (m: string): Compat | "other" => { - const s = m.toLowerCase(); - if (/(gpt-|dall-e|gpt-image|tts-1|gpt-4o|\bo[134](?:-|\b))/.test(s)) return "openai"; - if (/gemini/.test(s)) return "gemini"; - if (/claude/.test(s)) return "claude"; - return "other"; - }; - const isStable = (m: string) => !/(preview|experimental|beta|dev|test|sandbox|staging)/i.test(m); - const labelWeight = (s: string) => { - const l = s.toLowerCase(); - let w = 0; - if (/ultra/.test(l)) w += 0.09; if (/\bpro\b/.test(l)) w += 0.08; if (/opus/.test(l)) w += 0.08; - if (/sonnet/.test(l)) w += 0.07; if (/flash/.test(l)) w += 0.06; if (/haiku/.test(l)) w += 0.03; - if (/nano|lite|mini/.test(l)) w += 0.02; - return w; - }; - const popularPatterns: Record = { - openai: /gpt-4o|gpt-4\.?1|gpt-4-turbo|gpt-4|gpt-3\.5|gpt-image|tts-1|o[134]-?mini?/i, - claude: /claude-3\.?[57]-sonnet|claude-3-opus|claude-3-sonnet|claude-3-haiku|claude-2/i, - gemini: /gemini-2\.5|gemini-2\.0|gemini-1\.5|gemini-1\.0/i, - other: /deepseek|grok|llama-3|mistral|mixtral|qwen2|command-r/i - }; - const isPopularByFamily = (m: string, family: Compat | "other") => popularPatterns[family]?.test(m) ?? false; - const popularityWeight = (m: string, family: Compat | "other") => isPopularByFamily(m, family) ? 0.5 : 0; - const versionScore = (m: string, family: Compat | "other") => { - const s = String(m).toLowerCase(); - const numMatch = s.match(/(\d+(?:\.\d+)?)/); - let base = numMatch ? parseFloat(numMatch[1]) : 0; - if (/gpt-4o/.test(s)) base = Math.max(base, 4.01); - if (/tts-1/.test(s)) base = Math.max(base, 1.0); - return base + labelWeight(s) + popularityWeight(m, family); - }; - const sortCandidates = (_kind: "chat" | "search" | "image" | "tts", family: Compat | "other", list: string[]) => { - const preferred = list.filter(m => isPopularByFamily(m, family)); - const useList = preferred.length ? preferred : list; - const stable = useList.filter(m => isStable(m)); - const unstable = useList.filter(m => !isStable(m)); - const cmp = (a: string, b: string) => versionScore(b, family) - versionScore(a, family); - stable.sort(cmp); - unstable.sort(cmp); - return [...stable, ...unstable]; - }; - const pickAcrossKind = (kind: "chat" | "search" | "image" | "tts", preferredProvider?: string) => { - const providerOrder = (() => { - const names = entries.map(([n]) => n); - if (preferredProvider && names.includes(preferredProvider)) { - const rest = names.filter(n => n !== preferredProvider); - return [preferredProvider, ...rest]; - } - return names; - })(); - for (const fam of orders) { - for (const n of providerOrder) { - const bucket = bucketsBy[n]?.[kind] || []; - if (!bucket.length) continue; - const candidates = bucket.filter(m => modelFamilyOf(m) === fam); - if (!candidates.length) continue; - const sorted = sortCandidates(kind, fam, candidates); - const m = sorted[0]; - if (m) return { n, m, c: fam }; - } - } - for (const n of providerOrder) { - const bucket = bucketsBy[n]?.[kind] || []; - if (!bucket.length) continue; - const sorted = sortCandidates(kind, "other", bucket); - const m = sorted[0]; - if (m) return { n, m, c: "other" as const }; - } - return null as any; - }; - const chatPref = pick("chat")?.provider || undefined; - const searchPref = pick("search")?.provider || undefined; - const imagePref = pick("image")?.provider || undefined; - const ttsPref = pick("tts")?.provider || undefined; - const anchorProvider = chatPref || searchPref || imagePref || ttsPref || undefined; - const chatSel = pickAcrossKind("chat", anchorProvider); - const searchSel = pickAcrossKind("search", anchorProvider); - const imageSel = pickAcrossKind("image", anchorProvider); - const ttsSel = pickAcrossKind("tts", anchorProvider); - if (!chatSel) { - await msg.edit({ text: "❌ 未在任何已配置服务商中找到可用 chat 模型", parseMode: "html" }); - return; - } - const prev = { ...Store.data.models }; - Store.data.models.chat = `${chatSel.n} ${chatSel.m}`; - Store.data.models.search = searchSel ? `${searchSel.n} ${searchSel.m}` : prev.search; - Store.data.models.image = imageSel ? `${imageSel.n} ${imageSel.m}` : prev.image; - Store.data.models.tts = ttsSel ? `${ttsSel.n} ${ttsSel.m}` : prev.tts; - await Store.writeSoon(); - const cur = Store.data.models; - const detail = `✅ 已智能分配 chat/search/image/tts\n\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}`; - await msg.edit({ text: detail, parseMode: "html" }); - return; - } - const kind = a0 as keyof Models; - if (["chat", "search", "image", "tts"].includes(kind)) { - const [provider, ...mm] = args.slice(1); - const model = (mm.join(" ") || "").trim(); - if (!provider || !model) { - await msg.edit({ text: "❌ 参数不足", parseMode: "html" }); - return; - } - if (!Store.data.providers[provider]) { - await msg.edit({ text: "❌ 未知服务商", parseMode: "html" }); - return; - } - Store.data.models[kind] = `${provider} ${model}`; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已设置 ${kind}: ${html(Store.data.models[kind])}`, parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 model 子命令", parseMode: "html" }); - return; - } + const token = this.aiService.createAbortToken(); + await sendProcessing(msg, "image"); - /* ---------- 上下文管理 ---------- */ - if (subn === "context") { - const a0 = (args[0] || "").toLowerCase(); - const id = chatIdStr(msg); - if (a0 === "on") { - Store.data.contextEnabled = true; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已开启上下文", parseMode: "html" }); - return; - } - if (a0 === "off") { - Store.data.contextEnabled = false; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已关闭上下文", parseMode: "html" }); - return; - } - if (a0 === "show") { - const items = histFor(id); - const t = items.map(x => `${x.role}: ${html(x.content)}`).join("\n"); - await sendLong(msg, t || "(空)"); - return; - } - if (a0 === "del") { - const histItems = Store.data.histories[id] || []; - const count = histItems.length; - delete Store.data.histories[id]; - if (Store.data.histMeta) delete Store.data.histMeta[id]; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已清空本会话上下文(${count} 条记录)`, parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 context 子命令\n支持: on|off|show|del", parseMode: "html" }); - return; - } + try { + let prompt = ""; + if (promptInput && replyText && replyImageParts.length === 0) { + prompt = `${replyText}\n\n${promptInput}`; + } else if (promptInput && replyImageParts.length > 0) { + prompt = promptInput; + } else if (promptInput) { + prompt = promptInput; + } else { + prompt = replyText; + } - /* ---------- 折叠开关 ---------- */ - if (subn === "collapse") { - const a0 = (args[0] || "").toLowerCase(); - Store.data.collapse = a0 === "on"; - await Store.writeSoon(); - await msg.edit({ text: `✅ 消息折叠: ${Store.data.collapse ? "开启" : "关闭"}`, parseMode: "html" }); - return; + let images: AIImage[] = []; + if (imageParts.length > 0) { + let inputImage = await resolveImagePart(imageParts, this.httpClient, token); + if (!inputImage?.data) throw new Error("无法解析图片数据"); + if (inputImage.data && inputImage.mimeType !== "image/png") { + try { + const pngBuffer = await sharp(inputImage.data).png().toBuffer(); + inputImage = { data: pngBuffer, mimeType: "image/png" }; + } catch { } } + images = await this.aiService.editImage(prompt, inputImage, token); + } else { + images = await this.aiService.generateImage(prompt, token); + } + if (images.length === 0) throw new Error("AI回复为空"); + await this.messageUtils.sendImages(msg, images, prompt, replyToId, token); + await deleteMessageOrGroup(msg); + } finally { + this.aiService.releaseToken(token); + } + } +} - /* ---------- Telegraph ---------- */ - if (subn === "telegraph") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "on") { - Store.data.telegraph.enabled = true; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已开启 telegraph", parseMode: "html" }); - return; - } - if (a0 === "off") { - Store.data.telegraph.enabled = false; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已关闭 telegraph", parseMode: "html" }); - return; - } - if (a0 === "limit") { - const n = parseInt(args[1] || "0"); - Store.data.telegraph.limit = isFinite(n) ? n : 0; - await Store.writeSoon(); - await msg.edit({ text: `✅ 阈值: ${Store.data.telegraph.limit}`, parseMode: "html" }); - return; - } - if (a0 === "list") { - const list = Store.data.telegraph.posts.map((p, i) => `${i + 1}. ${html(p.title)} ${p.createdAt}`).join("\n") || "(空)"; - await sendLong(msg, `🧾 Telegraph 列表\n\n${list}`); - return; - } - if (a0 === "del") { - const t = (args[1] || "").toLowerCase(); - if (t === "all") Store.data.telegraph.posts = []; - else { - const i = parseInt(args[1] || "0") - 1; - if (i >= 0) Store.data.telegraph.posts.splice(i, 1); - } - await Store.writeSoon(); - await msg.edit({ text: "✅ 操作完成", parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 telegraph 子命令", parseMode: "html" }); - return; - } +class VideoFeature extends BaseFeatureHandler { + readonly name = "视频生成"; + readonly command = "video"; + readonly description = "生成视频"; - /* ---------- 音色管理 ---------- */ - if (subn === "voice") { - const a0 = (args[0] || "").toLowerCase(); - if (!Store.data.voices) Store.data.voices = { gemini: "Kore", openai: "alloy" }; - if (a0 === "list") { - const geminiList = GEMINI_VOICES.map((v, i) => `${i + 1}. ${v}`).join("\n"); - const openaiList = OPENAI_VOICES.map((v, i) => `${i + 1}. ${v}`).join("\n"); - const header = `🎤 可用音色列表\n\n当前配置:\nGemini: ${Store.data.voices.gemini}\nOpenAI: ${Store.data.voices.openai}\n\n`; - const collapsedContent = `Gemini (${GEMINI_VOICES.length}种):\n${geminiList}\n\nOpenAI (${OPENAI_VOICES.length}种):\n${openaiList}`; - const txt = header + `
${collapsedContent}
`; - await sendLong(msg, txt); - return; - } - if (a0 === "show") { - const txt = `🎤 当前音色配置\n\nGemini: ${Store.data.voices.gemini}\nOpenAI: ${Store.data.voices.openai}`; - await msg.edit({ text: txt, parseMode: "html" }); - return; - } - if (a0 === "gemini") { - const voiceName = args[1]; - if (!voiceName) { - await msg.edit({ text: `❌ 请指定音色名称\n当前: ${Store.data.voices.gemini}`, parseMode: "html" }); - return; - } - if (!GEMINI_VOICES.includes(voiceName as any)) { - await msg.edit({ text: `❌ 未知音色: ${html(voiceName)}\n使用 ai voice list 查看可用音色`, parseMode: "html" }); - return; - } - Store.data.voices.gemini = voiceName; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已设置 Gemini 音色: ${html(voiceName)}`, parseMode: "html" }); - return; - } - if (a0 === "openai") { - const voiceName = args[1]; - if (!voiceName) { - await msg.edit({ text: `❌ 请指定音色名称\n当前: ${Store.data.voices.openai}`, parseMode: "html" }); - return; - } - if (!OPENAI_VOICES.includes(voiceName as any)) { - await msg.edit({ text: `❌ 未知音色: ${html(voiceName)}\n使用 ai voice list 查看可用音色`, parseMode: "html" }); - return; - } - Store.data.voices.openai = voiceName; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已设置 OpenAI 音色: ${html(voiceName)}`, parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 voice 子命令\n支持: list|show|gemini <音色>|openai <音色>", parseMode: "html" }); - return; - } + private aiService: AIService; + private messageUtils: MessageUtils; - /* ---------- 超时设置 ---------- */ - if (subn === "timeout") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "show" || !a0) { - const current = Store.data.timeout || DEFAULT_TIMEOUT_MS; - await msg.edit({ text: `⏱️ 当前超时时间: ${current / 1000}秒`, parseMode: "html" }); - return; - } - if (a0 === "set") { - const val = args[1]; - if (!val) { - await msg.edit({ text: "❌ 请指定超时时间(秒)\n例如: ai timeout set 180 设置为180秒", parseMode: "html" }); - return; - } - const sec = parseInt(val); - if (!isFinite(sec) || sec < 10 || sec > 600) { - await msg.edit({ text: "❌ 超时时间必须在 10-600 秒之间(最多10分钟)", parseMode: "html" }); - return; - } - Store.data.timeout = sec * 1000; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已设置超时时间: ${sec}秒`, parseMode: "html" }); - return; - } - if (a0 === "reset") { - Store.data.timeout = DEFAULT_TIMEOUT_MS; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已重置超时时间为默认值: ${DEFAULT_TIMEOUT_MS / 1000}秒`, parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 timeout 子命令\n支持: show|set <秒>|reset", parseMode: "html" }); - return; - } + constructor(aiService: AIService, configManagerPromise: Promise, httpClient: HttpClient) { + super(configManagerPromise); + this.aiService = aiService; + this.messageUtils = new MessageUtils(configManagerPromise, httpClient); + } - /* ---------- 最大输出 Token 设置 ---------- */ - if (subn === "maxtokens" || subn === "tokens" || subn === "maxtoken") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "show" || !a0) { - const current = Store.data.maxTokens || DEFAULT_MAX_TOKENS; - const approxChars = Math.floor(current / 2); // 大约1 token = 0.5个中文字 - await msg.edit({ text: `📝 当前最大输出 Token: ${current}\n约等于 ${approxChars} 个中文字\n\n💡 生成超长文本时建议同时增加超时时间`, parseMode: "html" }); - return; - } - if (a0 === "set") { - const val = args[1]; - if (!val) { - await msg.edit({ text: "❌ 请指定最大 token 数\n例如: ai maxtokens set 32768 设置为32768", parseMode: "html" }); - return; - } - const num = parseInt(val); - if (!isFinite(num) || num < 100 || num > 128000) { - await msg.edit({ text: "❌ Token 数必须在 100-128000 之间", parseMode: "html" }); - return; - } - Store.data.maxTokens = num; - await Store.writeSoon(); - const approxChars = Math.floor(num / 2); - await msg.edit({ text: `✅ 已设置最大输出 Token: ${num}\n约等于 ${approxChars} 个中文字\n\n💡 建议同时设置超时: ai timeout set 300`, parseMode: "html" }); - return; - } - if (a0 === "reset") { - Store.data.maxTokens = DEFAULT_MAX_TOKENS; - await Store.writeSoon(); - await msg.edit({ text: `✅ 已重置最大输出 Token 为默认值: ${DEFAULT_MAX_TOKENS}`, parseMode: "html" }); - return; - } - await msg.edit({ text: "❌ 未知 maxtokens 子命令\n支持: show|set <数量>|reset", parseMode: "html" }); - return; - } + async execute(msg: Api.Message, args: string[], _prefixes: string[]): Promise { + const prefixes = getPrefixes(); + const configManager = await this.getConfigManager(); + const config = configManager.getConfig(); + const replyMsg = await msg.getReplyMessage(); + const replyToId = replyMsg?.id; + + const subCommand = args[1]?.toLowerCase(); + let imageMode: VideoImageMode = "auto"; + let promptStartIndex = 1; + if (subCommand === "preview") { + const state = args[2]?.toLowerCase(); + if (!state) { + await this.editMessage( + msg, + `🎬 视频预览状态:\n\n📄 当前状态: ${config.videoPreview ? "开启" : "关闭"}` + ); + return; + } + requireUser(state === "on" || state === "off", "参数必须是 on 或 off"); + await configManager.updateConfig((cfg) => { + cfg.videoPreview = state === "on"; + }); + await this.editMessage(msg, `✅ 视频预览已${state === "on" ? "开启" : "关闭"}`); + return; + } + if (subCommand === "audio") { + const state = args[2]?.toLowerCase(); + if (!state) { + await this.editMessage( + msg, + `🔊 视频音频状态:\n\n📄 当前状态: ${config.videoAudio ? "开启" : "关闭"}` + ); + return; + } + requireUser(state === "on" || state === "off", "参数必须是 on 或 off"); + await configManager.updateConfig((cfg) => { + cfg.videoAudio = state === "on"; + }); + await this.editMessage(msg, `✅ 视频音频已${state === "on" ? "开启" : "关闭"}`); + return; + } + if (subCommand === "duration") { + const duration = parseInt(args[2]); + if (!args[2]) { + await this.editMessage( + msg, + `⏱️ 视频时长:\n\n⏰ 当前时长: ${config.videoDuration} 秒` + ); + return; + } + requireUser(!isNaN(duration) && duration >= 5 && duration <= 20, "时长必须是 5-20 的整数"); + await configManager.updateConfig((cfg) => { + cfg.videoDuration = duration; + }); + await this.editMessage(msg, `✅ 视频时长已设置为 ${duration} 秒`); + return; + } + if (subCommand === "first") { + imageMode = "first"; + promptStartIndex = 2; + } else if (subCommand === "firstlast") { + imageMode = "firstlast"; + promptStartIndex = 2; + } - /* ---------- 链接预览开关 ---------- */ - if (subn === "preview") { - const a0 = (args[0] || "").toLowerCase(); - if (a0 === "on") { - Store.data.linkPreview = true; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已开启链接预览", parseMode: "html" }); - return; - } - if (a0 === "off") { - Store.data.linkPreview = false; - await Store.writeSoon(); - await msg.edit({ text: "✅ 已关闭链接预览", parseMode: "html" }); - return; - } - const current = Store.data.linkPreview !== false; - await msg.edit({ text: `🔗 链接预览: ${current ? "开启" : "关闭"}\n\n用法: ai preview on|off`, parseMode: "html" }); - return; - } + const promptInput = args.slice(promptStartIndex).join(" ").trim(); + const replyText = getMessageText(replyMsg).trim(); - /* ---------- 对话 / 搜索 ---------- */ - if (subn === "chat" || subn === "search" || !subn || isUnknownBareQuery) { - const replyMsg = await msg.getReplyMessage(); - const isSearch = subn === "search"; - const plain = (((isUnknownBareQuery ? [sub, ...args] : args).join(" ") || "").trim()); - - // 仿照 temp/ai (10).ts 的逻辑处理上下文 - let question = plain; - let context = ""; - - // 尝试智能获取媒体(支持图片、GIF、Sticker等) - // 如果有回复消息,优先用回复消息的媒体;否则尝试当前消息 - const mediaTarget = replyMsg && (replyMsg as any).media ? replyMsg : ((msg as any).media ? msg : null); - const mediaData = mediaTarget ? await downloadMessageMediaAsData(mediaTarget) : null; - const hasImage = !!mediaData; - - if (replyMsg) { - // 优先使用被引用的部分,如果没有则使用整条消息 - context = extractQuoteOrReplyText(msg, replyMsg).trim(); - // 如果回复的是图片消息但没有文字内容,补充说明 - if (!context && hasImage && mediaTarget === replyMsg) { - context = "[用户引用了一张图片]"; - } - } + const replyImageParts = await getMessageImageParts(replyMsg); + const messageImageParts = await getMessageImageParts(msg); - // 如果用户没有输入问题(只发了 .ai),则直接把引用消息当作问题 - if (!question && context) { - question = context; - context = ""; // 避免重复,既然当作问题了就不用作上下文了 - } + let finalPrompt = ""; + if (promptInput && replyText && replyImageParts.length === 0) { + finalPrompt = `${replyText}\n\n${promptInput}`; + } else if (promptInput && replyImageParts.length > 0) { + finalPrompt = promptInput; + } else if (promptInput) { + finalPrompt = promptInput; + } else { + finalPrompt = replyText; + } - if (!question && !hasImage) { - await msg.edit({ text: "❌ 请输入内容或回复一条消息", parseMode: "html" }); - return; - } + const allImageParts = [...replyImageParts, ...messageImageParts]; + const hasPrompt = !!finalPrompt.trim(); - // 构建最终发给 AI 的内容 (Prompt) - // 格式: - // 引用消息: - // [内容] - // - // 用户消息: - // [内容] - let q = question; - if (context) { - q = `引用消息:\n${context}\n\n用户消息:\n${question}`; - } - await msg.edit({ text: "🔄 处理中...", parseMode: "html" }); - const pre = await preflight(isSearch ? "search" : "chat"); - if (!pre) return; - const { m, p, compat } = pre; - - let content = ""; - let usedModel = m.model; - if (hasImage && mediaData) { - try { - const b64 = mediaData.buffer.toString("base64"); - const processedPrompt = applyPresetPrompt(q || "描述这张图片"); - // 传入 mime 以支持不同格式(虽然目前转为 PNG 或原格式) - content = await chatVision(p, compat, m.model, b64, processedPrompt, mediaData.mime); - } catch (e: any) { - await msg.edit({ text: `❌ 处理图片失败:${html(mapError(e, "vision"))}`, parseMode: "html" }); - return; - } - } else { - const res = await callChat(isSearch ? "search" : "chat", q, msg); - content = res.content; - usedModel = res.model; - } + requireUser(hasPrompt || allImageParts.length > 0, "至少需要一条提示"); - // 处理 AI 响应:提取内嵌图片,清理思考标签 - const processed = processAIResponse(content); - const replyToId = replyMsg?.id || 0; - const footTxt = footer(usedModel, isSearch ? "with Search" : ""); - - // 如果响应中包含内嵌图片,先发送图片 - if (processed.images.length > 0) { - for (const img of processed.images) { - try { - const buf = Buffer.from(img.data, "base64"); - const caption = img.alt ? `🖼️ ${html(img.alt)}` : `🖼️ AI 生成的图片`; - await sendImageFile(msg, buf, caption + footTxt, replyToId, img.mime); - } catch { - // 图片发送失败,继续处理 - } - } - // 如果只有图片没有其他文字内容,删除原消息并返回 - const textContent = processed.text.replace(/\[图片\]/g, "").replace(/\[图片数据\]/g, "").trim(); - if (!textContent || textContent.length < 10) { - try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } - return; - } - // 如果还有文字内容,继续处理 - content = processed.text; - } else { - // 即使没有图片也要清理思考标签 - content = processed.text; - } + if (!config.currentVideoTag || !config.currentVideoModel || !config.configs[config.currentVideoTag]) { + throw new UserError( + `请先配置API并设置模型\n使用 ${prefixes[0]}ai config add 和 ${prefixes[0]}ai model video ` + ); + } - const full = formatQA(q || "(图片)", content); + const token = this.aiService.createAbortToken(); + await sendProcessing(msg, "video"); - if (Store.data.telegraph.enabled && Store.data.telegraph.limit > 0 && full.length > Store.data.telegraph.limit) { - const tgContent = `Q: ${q || "(图片)"}\n\nA: ${content}`; - const urls = await createTGPage("TeleBox AI", tgContent); - if (urls.length > 0) { - // 保存历史记录(倒序插入,保持时间顺序) - for (let i = urls.length - 1; i >= 0; i--) { - Store.data.telegraph.posts.unshift({ title: (q || "图片").slice(0, 30) || "AI", url: urls[i], createdAt: nowISO() }); - } - Store.data.telegraph.posts = Store.data.telegraph.posts.slice(0, 10); - await Store.writeSoon(); - - const links = urls.map((u, i) => { - const num = urls.length > 1 ? (['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][i] || (i + 1)) : ''; - return `🔗 点我阅读内容${num}`; - }).join("\n\n"); - const linkText = `📰 内容较长,Telegraph观感更好喔:\n\n${links}`; - - const tgMsg = `Q:\n${q || "(图片)"}\n\nA:\n${linkText}\n${footTxt}`; - try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } - if (msg.client) { - await msg.client.sendMessage(msg.peerId, { - message: tgMsg, - parseMode: "html", - replyTo: replyToId || undefined, - linkPreview: Store.data.linkPreview !== false - }); - } - return; - } - } - // 发送结果并删除原消息 - try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } - const chunks = buildChunks(full, Store.data.collapse, footTxt); - if (msg.client && chunks.length > 0) { - const peer = msg.peerId; - for (const chunk of chunks) { - await msg.client.sendMessage(peer, { - message: chunk, - parseMode: "html", - replyTo: replyToId || undefined - }); - } - } - return; + try { + let imageParts = allImageParts; + + if (imageMode === "firstlast" && allImageParts.length < 2) { + if (allImageParts.length === 1) { + imageMode = "first"; + } else if (hasPrompt) { + imageMode = "auto"; + imageParts = []; } + } - /* ---------- 生图 ---------- */ - if (subn === "image") { - const replyMsg = await msg.getReplyMessage(); - const fullText = (msg as any).text || (msg as any).message || ""; - const imagePromptMatch = fullText.match(/^[.\-\/!]ai\s+(?:image|img|i)\s+([\s\S]*)$/im); - const userInput = (imagePromptMatch ? imagePromptMatch[1].trim() : args.join(" ").trim()); - const replyContent = extractQuoteOrReplyText(msg, replyMsg).trim(); - - // 检查是否有回复的图片(图生图模式) - const mediaTarget = replyMsg && (replyMsg as any).media ? replyMsg : null; - const mediaData = mediaTarget ? await downloadMessageMediaAsData(mediaTarget) : null; - const hasSourceImage = !!mediaData; - - // 结合用户输入和引用内容 - let prm = ""; - if (userInput) { - prm = userInput; - } else if (replyContent && !hasSourceImage) { - prm = replyContent; - } else if (hasSourceImage) { - prm = "请基于这张图片进行创作"; - } - if (!prm && !hasSourceImage) { - await msg.edit({ text: "❌ 请输入提示词", parseMode: "html" }); - return; - } - const pre = await preflight("image"); - if (!pre) return; - const { m, p, compat } = pre; - const replyToId = replyMsg?.id || 0; - - await msg.edit({ text: hasSourceImage ? "🎨 图生图处理中..." : "🎨 生成中...", parseMode: "html" }); - - if (compat === "openai") { - // OpenAI 兼容模式:支持图生图 - const sourceImage = hasSourceImage && mediaData ? { - data: mediaData.buffer.toString("base64"), - mime: mediaData.mime - } : undefined; - const b64 = await imageOpenAI(p, m.model, prm, sourceImage); - if (!b64) { - await msg.edit({ text: "❌ 图片生成失败:服务无有效输出", parseMode: "html" }); - return; - } - const buf = Buffer.from(b64, "base64"); - const caption = hasSourceImage ? `🖼️ AI 图生图` : `🖼️ AI 生成图片`; - await sendImageFile(msg, buf, caption + footer(m.model), replyToId); - await msg.delete(); - return; - } else if (compat === "gemini") { - try { - // 如果有源图片,传入图生图模式 - const sourceImage = hasSourceImage && mediaData ? { - data: mediaData.buffer.toString("base64"), - mime: mediaData.mime - } : undefined; - const { image, text, mime } = await imageGemini(p, m.model, prm, sourceImage); - if (image) { - const caption = hasSourceImage ? `🖼️ AI 图生图` : `🖼️ AI 生成图片`; - await sendImageFile(msg, image, caption + footer(m.model), replyToId, mime); - await msg.delete(); - return; - } - if (text) { - const textOut = formatQA(prm, text); - await sendLongAuto(msg, textOut, replyToId, { collapse: Store.data.collapse }, footer(m.model)); - await msg.delete(); - return; - } - await msg.edit({ text: "❌ 图片生成失败:服务无有效输出", parseMode: "html" }); - return; - } catch (e: any) { - await msg.edit({ text: `❌ 图片生成失败:${html(mapError(e, "image"))}`, parseMode: "html" }); - return; - } - } else { - await msg.edit({ text: "❌ 当前服务商不支持图片生成功能", parseMode: "html" }); - return; - } + if (imageMode === "first" && allImageParts.length < 1) { + if (hasPrompt) { + imageMode = "auto"; + imageParts = []; } + } + if (imageMode === "first") { + imageParts = allImageParts.slice(0, 1); + } else if (imageMode === "firstlast") { + imageParts = allImageParts.slice(0, 2); + } else if (allImageParts.length > 0) { + imageMode = "reference"; + imageParts = allImageParts.slice(0, 4); + } - /* ---------- 语音回答 ---------- */ - if (subn === "audio" || subn === "searchaudio") { - const replyMsg = await msg.getReplyMessage(); - const plain = (args.join(" ") || "").trim(); + const videos = await this.aiService.generateVideo(finalPrompt, imageParts, imageMode, token); + if (videos.length === 0) throw new Error("AI回复为空"); + await this.messageUtils.sendVideos(msg, videos, finalPrompt, replyToId, token); + await deleteMessageOrGroup(msg); + } finally { + this.aiService.releaseToken(token); + } + } +} - // 仿照 temp/ai (10).ts 的逻辑处理上下文 (Voice版) - let question = plain; - let context = ""; +class AIPlugin extends Plugin { + name = "ai"; + + private cleanedUp = false; + + private aiService: AIService; + private httpClient: HttpClient; + private featureRegistry: FeatureRegistry; + private questionFeature: QuestionFeature; + private configManagerPromise: Promise; + + constructor() { + super(); + this.configManagerPromise = ConfigManager.getInstance(); + this.httpClient = new HttpClient(this.configManagerPromise); + this.aiService = new AIService(this.configManagerPromise, this.httpClient); + this.featureRegistry = new FeatureRegistry(); + this.questionFeature = new QuestionFeature(this.aiService, this.configManagerPromise, this.httpClient); + this.registerFeatures(); + } - if (replyMsg) { - context = extractQuoteOrReplyText(msg, replyMsg).trim(); - } + private getMainPrefix(): string { + const prefixes = getPrefixes(); + return prefixes[0] || ""; + } - if (!question && context) { - question = context; - context = ""; - } + private registerFeatures(): void { + this.featureRegistry.register(new ConfigFeature(this.configManagerPromise)); + this.featureRegistry.register(new ModelFeature(this.configManagerPromise)); + this.featureRegistry.register(new PromptFeature(this.configManagerPromise)); + this.featureRegistry.register(new CollapseFeature(this.configManagerPromise)); + this.featureRegistry.register(new TelegraphFeature(this.configManagerPromise)); + this.featureRegistry.register(new TimeoutFeature(this.configManagerPromise)); + this.featureRegistry.register(new SearchFeature(this.aiService, this.configManagerPromise, this.httpClient)); + this.featureRegistry.register(new ImageFeature(this.aiService, this.configManagerPromise, this.httpClient)); + this.featureRegistry.register(new VideoFeature(this.aiService, this.configManagerPromise, this.httpClient)); + } - if (!question) { await msg.edit({ text: "❌ 请输入内容或回复一条消息", parseMode: "html" }); return; } + description = async (): Promise => { + const mainPrefix = this.getMainPrefix(); + const config = (await this.configManagerPromise).getConfig(); + + const baseDescription = `🤖 智能AI助手 + +⚙️ API配置: +• ${mainPrefix}ai config add <tag> <url> <key> - 添加API配置 +• ${mainPrefix}ai config del <tag> - 删除API配置 + +🧠 模型设置: +• ${mainPrefix}ai model chat <tag> <model-path> - 设置聊天模型 +• ${mainPrefix}ai model search <tag> <model-path> - 设置搜索模型 +• ${mainPrefix}ai model image <tag> <model-path> - 设置图片模型 +• ${mainPrefix}ai model video <tag> <model-path> - 设置视频模型 + +💬 提问: +• ${mainPrefix}ai <input> - 向AI发起提问 +• ${mainPrefix}ai search <input> - 联网搜索并回答 +• ${mainPrefix}ai image <prompt> - 文生/编辑图片 +• ${mainPrefix}ai video <prompt> - 文生/参考图生成视频 +• ${mainPrefix}ai video first <prompt> - 首帧生成视频 +• ${mainPrefix}ai video firstlast <prompt> - 首尾帧生成视频 + +✍️ 提示词: +• ${mainPrefix}ai prompt set <input> - 设置提示词 +• ${mainPrefix}ai prompt del - 删除提示词 + +🧩 消息设置: +• ${mainPrefix}ai image preview on|off - 开/关图片预览 +• ${mainPrefix}ai video preview on|off - 开/关视频预览 +• ${mainPrefix}ai video audio on|off - 开/关视频音频 +• ${mainPrefix}ai collapse on|off - 开/关消息折叠 +• ${mainPrefix}ai video duration <sec> - 视频输出时长 +• ${mainPrefix}ai timeout <sec> - 设置超时时间 + +📰 Telegraph: +• ${mainPrefix}ai telegraph on - 开启Telegraph +• ${mainPrefix}ai telegraph off - 关闭Telegraph +• ${mainPrefix}ai telegraph limit <integer> - 设置容量 +• ${mainPrefix}ai telegraph del <number/all> - 删除记录 + +📌 使用说明: +• 不携带参数可进行查询 +• 回复消息可进行补充提问 +`; + if (!config.collapse) return baseDescription; + return `
${baseDescription}
`; + }; - // 构建 Prompt - let q = question; - if (context) { - q = `引用消息:\n${context}\n\n用户消息:\n${question}`; - } - await msg.edit({ text: "🔄 处理中...", parseMode: "html" }); - const res = await callChat(subn === "searchaudio" ? "search" : "chat", q, msg); - await executeTTS(msg, res.content, replyMsg?.id || 0); + cmdHandlers: Record Promise> = { + ai: async (msg: Api.Message, trigger?: Api.Message) => { + try { + const prefixes = getPrefixes(); + const args = getMessageText(msg).trim().split(/\s+/).slice(1); + + if (args.length === 0) { + await this.questionFeature.askFromReply(msg, trigger); return; } - - /* ---------- TTS ---------- */ - if (subn === "tts") { - const replyMsg = await msg.getReplyMessage(); - const t = (args.join(" ") || "").trim() || extractQuoteOrReplyText(msg, replyMsg).trim(); - if (!t) { await msg.edit({ text: "❌ 请输入文本", parseMode: "html" }); return; } - await executeTTS(msg, t, replyMsg?.id || 0); + const sub = args[0].toLowerCase(); + if (sub === "help" || sub === "?") { + const description = await this.description(); + await MessageSender.sendOrEdit(trigger || msg, description, { parseMode: "html" }); return; } + const handler = this.featureRegistry.getHandler(sub); - /* ---------- 兜底 ---------- */ - await msg.edit({ text: "❌ 未知子命令", parseMode: "html" }); - return; - } catch (e: any) { - await msg.edit({ text: `❌ 出错:${html(mapError(e, subn))}`, parseMode: "html" }); - return; + if (handler) await handler.execute(msg, args, prefixes); + else await this.questionFeature.execute(msg, args, prefixes); + } catch (error: any) { + await sendErrorMessage(msg, error, trigger); } - } + }, }; - // 资源清理方法 - 防止内存泄漏 async cleanup(): Promise { - // 真实资源清理:释放插件持有的定时器、监听器、运行时状态或临时资源。 - try { - // 清理 Store 写入定时器 - if (Store._writeTimer) { - clearTimeout(Store._writeTimer); - Store._writeTimer = null; - } - // 清理模型刷新防抖定时器 - if (refreshDebounceTimer) { - clearTimeout(refreshDebounceTimer); - refreshDebounceTimer = null; - } - // 清理兼容性解析缓存 - compatResolving.clear(); - // 清理 Anthropic 版本缓存 - anthropicVersionCache.clear(); - } catch { - // 清理失败时静默处理 + if (this.cleanedUp) { + return; } + this.cleanedUp = true; + + this.questionFeature.cancelCurrentOperation(); + await this.aiService.destroy(); + const configManager = await this.configManagerPromise; + await configManager.destroy(); } } -export default new AiPlugin(); +export default new AIPlugin(); diff --git a/autodel/autodel.ts b/autodel/autodel.ts index 71dc04b8..80d647f4 100644 --- a/autodel/autodel.ts +++ b/autodel/autodel.ts @@ -12,8 +12,13 @@ const mainPrefix = prefixes[0]; class AutoDelPlugin extends Plugin { cleanup(): void { - // 引用重置:清空实例级 db / cache / manager 引用,便于 reload 后重新初始化。 + if (this.db) { + try { + this.db.close(); + } catch {} + } this.db = null; + this.settings.clear(); } description: string = `🕒 定时自动删除消息

diff --git a/autorepeat/autorepeat.ts b/autorepeat/autorepeat.ts index 7f212195..d807b485 100644 --- a/autorepeat/autorepeat.ts +++ b/autorepeat/autorepeat.ts @@ -365,6 +365,14 @@ class AutoRepeatManager { this.saveDailyHistory(); // 保存新的天数和空的记录 } } + + static cleanup(): void { + this.recentMessages.clear(); + this.dailyHistory.clear(); + this.enabledGroups.clear(); + this.lastCleanup = 0; + this.lastDayCheck = 0; + } } // 初始化 @@ -664,6 +672,7 @@ class CommandHandlers { // ==================== 插件主类 ==================== class AutoRepeatPlugin extends Plugin { cleanup(): void { + AutoRepeatManager.cleanup(); } // 修改类名 description: string = HELP_TEXT; diff --git a/bgp/bgp.ts b/bgp/bgp.ts new file mode 100644 index 00000000..4200ec42 --- /dev/null +++ b/bgp/bgp.ts @@ -0,0 +1,489 @@ +import { Plugin } from "@utils/pluginBase"; +import { getGlobalClient } from "@utils/globalClient"; +import { Api } from "teleproto"; +import axios from "axios"; +import sharp from "sharp"; +import * as fs from "fs"; +import * as path from "path"; +import { createDirectoryInTemp } from "@utils/pathHelpers"; +import * as cheerio from "cheerio"; + +function htmlEscape(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const BGP_COMMON_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36", + "Accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate", + "Referer": "https://bgp.tools/", + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Upgrade-Insecure-Requests": "1", + "Cache-Control": "max-age=0", + "Sec-Ch-Ua": + "\"Chromium\";v=\"122\", \"Google Chrome\";v=\"122\", \"Not=A?Brand\";v=\"99\"", + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": "\"Windows\"", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Sec-Fetch-Dest": "document", + "Dnt": "1", + "Sec-Gpc": "1", + "Pragma": "no-cache", +}; + +function isValidIPv4(ip: string): boolean { + const parts = ip.split("."); + if (parts.length !== 4) return false; + return parts.every((p) => { + const n = Number(p); + return Number.isInteger(n) && n >= 0 && n <= 255; + }); +} + +function networkAddress(ip: string, mask: number): string | null { + if (!isValidIPv4(ip)) return null; + + const parts = ip.split(".").map((p) => Number(p)); + const ipNum = + (((parts[0] << 24) | + (parts[1] << 16) | + (parts[2] << 8) | + parts[3]) >>> 0); + + const maskNum = mask === 0 ? 0 : ((~0 << (32 - mask)) >>> 0); + const netNum = ipNum & maskNum; + + const netParts = [ + (netNum >>> 24) & 255, + (netNum >>> 16) & 255, + (netNum >>> 8) & 255, + netNum & 255, + ]; + + return `${netParts.join(".")}/${mask}`; +} + +function normalizeIP(input: string): string | null { + const clean = input.trim(); + if (!clean || clean.includes("/")) return null; + const m = clean.match(/^(\d{1,3}(?:\.\d{1,3}){3})$/); + if (!m) return null; + const ip = m[1]; + if (!isValidIPv4(ip)) return null; + return ip; +} + +function extractIPFromText(text: string): string | null { + const cidrLike = /(\d{1,3}(?:\.\d{1,3}){3})\/\d{1,2}/; + const m1 = cidrLike.exec(text); + if (m1) { + const ip = normalizeIP(m1[1]); + if (ip) return ip; + } + + const ipRegex = /(\d{1,3}(?:\.\d{1,3}){3})/; + const m2 = ipRegex.exec(text); + if (m2) { + const ip = normalizeIP(m2[1]); + if (ip) return ip; + } + + return null; +} + +function isPlaceholderSvg(svgText: string): boolean { + return svgText.includes("Not_Visible") && svgText.includes("in_DFZ"); +} + +type BgpFetchResult = + | { status: "ok"; svgBuffer: Buffer; usedPrefix: string } + | { status: "placeholder"; usedPrefix: string | null } + | { status: "none" }; + +async function fetchBgpSvgWithFallback(ip: string): Promise { + if (!isValidIPv4(ip)) return { status: "none" }; + + const prefixesToTry: string[] = []; + const p24 = networkAddress(ip, 24); + if (p24) prefixesToTry.push(p24); + const p23 = networkAddress(ip, 23); + if (p23 && p23 !== p24) prefixesToTry.push(p23); + + let placeholderPrefix: string | null = null; + + for (const prefix of prefixesToTry) { + const urlIP = prefix.replace("/", "_"); + const url = `https://bgp.tools/pathimg/rt-${urlIP}?4c1db184-e649-4491-8b7f-06177bcb4f25&loggedin`; + + try { + const response = await axios.get(url, { + headers: BGP_COMMON_HEADERS, + responseType: "arraybuffer", + timeout: 15000, + }); + + const svgBuffer = Buffer.from(response.data); + const svgText = svgBuffer.toString("utf-8"); + + if (isPlaceholderSvg(svgText)) { + placeholderPrefix = prefix; + continue; + } + + return { status: "ok", svgBuffer, usedPrefix: prefix }; + } catch (err: any) { + if (err.response?.status === 404) continue; + continue; + } + } + + if (placeholderPrefix) { + return { status: "placeholder", usedPrefix: placeholderPrefix }; + } + + return { status: "none" }; +} + +async function fetchDnsWithFallback(ip: string): Promise<{ dnsLines: string[]; usedPrefix: string }> { + if (!isValidIPv4(ip)) { + throw new Error("无效的IP地址"); + } + + const prefixesToTry: string[] = []; + const p24 = networkAddress(ip, 24); + if (p24) prefixesToTry.push(p24); + const p23 = networkAddress(ip, 23); + if (p23 && p23 !== p24) prefixesToTry.push(p23); + + for (const prefix of prefixesToTry) { + const url = `https://bgp.tools/prefix/${prefix}#dns`; + + try { + const response = await axios.get(url, { + headers: BGP_COMMON_HEADERS, + timeout: 15000, + }); + + const dnsResult = extractDNSData(response.data); + + if (dnsResult.dnsLines.length > 0) { + return { dnsLines: dnsResult.dnsLines, usedPrefix: prefix }; + } + } catch (err: any) { + if (err.response?.status === 404) continue; + continue; + } + } + + throw new Error("未找到DNS记录"); +} + +function extractTopLevelDomain(domain: string): string { + const parts = domain.split("."); + if (parts.length >= 2) return parts.slice(-2).join("."); + return domain; +} + +function extractDNSData(html: string): { + dnsLines: string[]; + totalRecords: number; + filteredRecords: number; +} { + const dnsLines: string[] = []; + const ipDomainMap = new Map(); + const domainRecords: Array<{ ip: string; domain: string; topLevelDomain: string }> = []; + + try { + const $ = cheerio.load(html); + const allText = $.text(); + + const ipDomainPattern = + /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + + let match: RegExpExecArray | null; + while ((match = ipDomainPattern.exec(allText)) !== null) { + const ip = match[1]; + const domain = match[2]; + const top = extractTopLevelDomain(domain); + + domainRecords.push({ ip, domain, topLevelDomain: top }); + if (!ipDomainMap.has(ip)) ipDomainMap.set(ip, []); + ipDomainMap.get(ip)!.push(domain); + } + + const domainCount = new Map(); + domainRecords.forEach((r) => { + domainCount.set(r.topLevelDomain, (domainCount.get(r.topLevelDomain) || 0) + 1); + }); + + const filtered = new Set(); + domainCount.forEach((count, dom) => { + if (count > 2) filtered.add(dom); + }); + + ipDomainMap.forEach((domains, ip) => { + domains.forEach((domain) => { + const top = extractTopLevelDomain(domain); + if (!filtered.has(top)) dnsLines.push(`${ip}\t${domain}`); + }); + }); + + return { + dnsLines, + totalRecords: domainRecords.length, + filteredRecords: domainRecords.length - dnsLines.length, + }; + } catch { + return { dnsLines: [], totalRecords: 0, filteredRecords: 0 }; + } +} + +async function resolveTargetIP( + args: string[], + msg: Api.Message, + trigger?: Api.Message, +): Promise { + const rawInput = args.join(" ").trim(); + + if (rawInput) { + const ipFromArgs = extractIPFromText(rawInput); + if (ipFromArgs) return ipFromArgs; + } + + if (trigger?.message) { + const ipFromTrigger = extractIPFromText(trigger.message); + if (ipFromTrigger) return ipFromTrigger; + } + + if (msg.replyTo) { + const r = await msg.getReplyMessage(); + if (r?.message) { + const ipFromReply = extractIPFromText(r.message); + if (ipFromReply) return ipFromReply; + } + } + + return null; +} + +class BGPPlugin extends Plugin { + name = "bgp"; + + description = + "\n🌐 BGP路由图查询工具\n" + + "\n• .bgp <IP> - 查询指定IP的BGP路由图\n" + + "• .bgp - 回复包含IP的消息自动查询BGP路由图\n" + + "• .bgp dns <IP> - 查询指定IP的DNS解析记录\n" + + "• .bgp dns - 回复包含IP的消息查询DNS解析记录"; + + cmdHandlers: Record Promise> = { + bgp: async (msg, trigger) => { + + const client = await getGlobalClient(); + if (!client) { + await msg.edit({ text: "❌ 客户端未初始化" }); + return; + } + + if (!client.connected) { + try { + await msg.edit({ text: "🔄 正在连接 Telegram..." }); + await client.connect(); + if (!client.connected) throw new Error("连接失败"); + } catch (err: any) { + await msg.edit({ + text: + `❌ 连接失败\n\n${htmlEscape(err.message)}\n\n请检查:\n• 网络连接\n• API 凭据\n• 代理设置`, + parseMode: "html", + }); + return; + } + } + + let msgDeleted = false; + + try { + let targetIP: string | null = null; + const rawArgs = msg.message.split(" ").slice(1); + + if (rawArgs[0] === "dns") { + const dnsArgs = rawArgs.slice(1); + targetIP = await resolveTargetIP(dnsArgs, msg, trigger); + + if (!targetIP) { + await msg.edit({ + text: + `❌ 请提供有效的IP地址\n\n支持的格式:\n` + + `• .bgp dns 1.1.1.1\n` + + `• .bgp dns 1.1.1.0/24\n` + + `• 回复包含IP的消息使用 .bgp dns`, + parseMode: "html", + }); + return; + } + + await msg.edit({ text: `🔍 正在查询DNS解析记录...`, parseMode: "html" }); + + try { + const result = await fetchDnsWithFallback(targetIP); + + let output = "A\tDNS\n"; + output += result.dnsLines.join("\n"); + + const formattedOutput = + `
${output}
\n\n` + + `🌐 DNS解析记录\n\n` + + `${htmlEscape(targetIP)}\n` + + `使用前缀: ${htmlEscape(result.usedPrefix)}\n\n` + + `⏰ ${new Date().toLocaleString("zh-CN")}`; + + await msg.edit({ text: formattedOutput, parseMode: "html" }); + + } catch (err: any) { + const message = err?.message || ""; + + if (message.includes("未找到DNS记录")) { + const prefixForLink = + networkAddress(targetIP, 24) || `${targetIP}/24`; + + await msg.edit({ + text: + `❌ 未找到DNS解析记录\n\n` + + `请确认该前缀是否在公网上有宣告或有可见的 DNS 记录\n\n` + + `🔗 直达链接: https://bgp.tools/prefix/${htmlEscape(prefixForLink)}#dns`, + parseMode: "html", + }); + } else { + await msg.edit({ + text: + `❌ DNS查询失败\n\n${htmlEscape(message || "未知错误")}`, + parseMode: "html", + }); + } + } + + return; + } + + targetIP = await resolveTargetIP(rawArgs, msg, trigger); + + if (!targetIP) { + await msg.edit({ + text: + `❌ 请提供有效的IP地址\n\n支持的格式:\n` + + `• .bgp 1.1.1.1\n` + + `• .bgp 1.1.1.0/24\n` + + `• 回复包含IP的消息使用 .bgp`, + parseMode: "html", + }); + return; + } + + await msg.edit({ text: `🔍 正在生成BGP路由图...`, parseMode: "html" }); + + const tempDir = createDirectoryInTemp("bgp_images"); + if (!tempDir) throw new Error("无法创建临时目录"); + + const fileIP = targetIP; + const svgFileName = `bgp-${fileIP}.svg`; + const pngFileName = `bgp-${fileIP}.png`; + const svgPath = path.join(tempDir, svgFileName); + const pngPath = path.join(tempDir, pngFileName); + + try { + const result = await fetchBgpSvgWithFallback(targetIP); + + if (result.status === "ok") { + fs.writeFileSync(svgPath, result.svgBuffer); + + await sharp(svgPath, { density: 300 }) + .resize({ + width: 2400, + height: 1800, + fit: "inside", + withoutEnlargement: false, + }) + .png({ + quality: 95, + compressionLevel: 6, + adaptiveFiltering: true, + palette: true, + }) + .sharpen(1.2, 1.0, 2.0) + .toFile(pngPath); + + try { + await msg.delete(); + msgDeleted = true; + } catch {} + + await client.sendFile(msg.chatId!, { + file: pngPath, + caption: + `🌐 BGP路由图\n\n` + + `${htmlEscape(targetIP)}\n` + + `使用前缀: ${htmlEscape(result.usedPrefix)}\n\n` + + `⏰ ${new Date().toLocaleString("zh-CN")}`, + parseMode: "html", + }); + + } else if (result.status === "placeholder") { + const prefixForLink = + result.usedPrefix || + networkAddress(targetIP, 24) || + `${targetIP}/24`; + + await msg.edit({ + text: + `❌ 没有可用的BGP路由图\n\n` + + `当前前缀 ${htmlEscape(prefixForLink)} 在 DFZ 中不可见或没有路径数据\n\n` + + `🔗 直达链接: https://bgp.tools/prefix/${htmlEscape(prefixForLink)}`, + parseMode: "html", + }); + + } else { + const prefixForLink = + networkAddress(targetIP, 24) || `${targetIP}/24`; + + await msg.edit({ + text: + `❌ 未找到可用的BGP路由图\n\n` + + `请确认该前缀是否在公网上有宣告\n\n` + + `🔗 直达链接: https://bgp.tools/prefix/${htmlEscape(prefixForLink)}`, + parseMode: "html", + }); + } + + } finally { + try { + if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath); + if (fs.existsSync(pngPath)) fs.unlinkSync(pngPath); + } catch {} + } + + } catch (err: any) { + const errText = `❌ BGP查询失败\n\n${htmlEscape(err.message || "未知错误")}`; + try { + if (msgDeleted) { + await client.sendMessage(msg.chatId!, { message: errText, parseMode: "html" }); + } else { + await msg.edit({ text: errText, parseMode: "html" }); + } + } catch {} + } + }, + }; + + cleanup(): void {} +} + +export default new BGPPlugin(); diff --git a/convert/convert.ts b/convert/convert.ts index d2fa4a8c..11d2261b 100644 --- a/convert/convert.ts +++ b/convert/convert.ts @@ -32,7 +32,7 @@ const GEMINI_CONFIG_DB_PATH = path.join(dbDir, "gemini_config.db"); const GEMINI_API_KEY = "convert_gemini_api_key"; class GeminiConfigManager { - private static db: Database.Database; + private static db: Database.Database | null = null; private static initialized = false; private static init(): void { @@ -73,6 +73,16 @@ class GeminiConfigManager { console.error("[convert] Failed to save config:", error); } } + + static cleanup(): void { + if (this.db) { + try { + this.db.close(); + } catch {} + } + this.db = null; + this.initialized = false; + } } class GeminiClient { @@ -281,6 +291,7 @@ const help_text = toSimplified(`🎬 视频转音频 AI 助手 class ConvertPlugin extends Plugin { cleanup(): void { + GeminiConfigManager.cleanup(); } description: string = help_text; diff --git a/hitokoto/hitokoto.ts b/hitokoto/hitokoto.ts index 64c52646..d9a2c89d 100644 --- a/hitokoto/hitokoto.ts +++ b/hitokoto/hitokoto.ts @@ -80,12 +80,10 @@ class HitokotoPlugin extends Plugin { */ private async handleHitokotoCommand(msg: Api.Message): Promise { try { - // 解析参数 const parts = msg.text?.trim().split(/\s+/) || []; const subCommand = parts[1]?.toLowerCase() || ""; - // 处理 help/h 子指令或无参数情况 - if (!subCommand || subCommand === "help" || subCommand === "h") { + if (subCommand === "help" || subCommand === "h") { await msg.edit({ text: help_text, parseMode: "html" @@ -95,7 +93,6 @@ class HitokotoPlugin extends Plugin { const params = this.parseTypeParams(parts.slice(1)); - // 如果不是 help/h,则执行获取一言功能 await this.fetchAndSendHitokoto(msg, params); } catch (error: any) { diff --git a/komari/komari.ts b/komari/komari.ts index 5d5277f1..03106731 100644 --- a/komari/komari.ts +++ b/komari/komari.ts @@ -24,7 +24,7 @@ if (!fs.existsSync(path.dirname(CONFIG_DB_PATH))) { // 配置管理器 - 使用SQLite数据库 class ConfigManager { - private static db: Database.Database; + private static db: Database.Database | null = null; private static initialized = false; // 初始化数据库 @@ -48,6 +48,7 @@ class ConfigManager { static get(key: string, defaultValue?: string): string { this.init(); + if (!this.db) return defaultValue || ""; try { const stmt = this.db.prepare("SELECT value FROM config WHERE key = ?"); @@ -65,6 +66,7 @@ class ConfigManager { static set(key: string, value: string): void { this.init(); + if (!this.db) return; try { const stmt = this.db.prepare(` @@ -80,6 +82,7 @@ class ConfigManager { // 获取所有配置 static getAll(): { [key: string]: string } { this.init(); + if (!this.db) return {}; try { const stmt = this.db.prepare("SELECT key, value FROM config"); @@ -100,6 +103,7 @@ class ConfigManager { // 删除配置 static delete(key: string): void { this.init(); + if (!this.db) return; try { const stmt = this.db.prepare("DELETE FROM config WHERE key = ?"); @@ -112,8 +116,12 @@ class ConfigManager { // 关闭数据库连接 static close(): void { if (this.db) { - this.db.close(); + try { + this.db.close(); + } catch {} } + this.db = null; + this.initialized = false; } } @@ -649,6 +657,7 @@ async function handleKomariRequest(msg: Api.Message): Promise { class KomariPlugin extends Plugin { cleanup(): void { + ConfigManager.close(); } description: string = ` diff --git a/lottery/lottery.ts b/lottery/lottery.ts index e679a7aa..f6074021 100644 --- a/lottery/lottery.ts +++ b/lottery/lottery.ts @@ -1728,7 +1728,12 @@ const lottery = async (msg: Api.Message) => { class LotteryPlugin extends Plugin { cleanup(): void { - // 当前插件不持有需要在 reload 时额外释放的长期资源。 + if (db) { + try { + db.close(); + } catch {} + db = null as any; + } } description: string = help_text; diff --git a/lu_bs/lu_bs.ts b/lu_bs/lu_bs.ts index 1adf2140..4f80ae1f 100644 --- a/lu_bs/lu_bs.ts +++ b/lu_bs/lu_bs.ts @@ -37,8 +37,8 @@ const HELP_TEXT = `🕒 鲁小迅整点报时 class LuBsPlugin extends Plugin { cleanup(): void { - // 引用重置:清空实例级 db / cache / manager 引用,便于 reload 后重新初始化。 this.db = null; + this.stickerSet = null; } private db: any = null; diff --git a/music/music.ts b/music/music.ts index 2607bb7d..3cdcc4af 100644 --- a/music/music.ts +++ b/music/music.ts @@ -45,6 +45,7 @@ const prefixes = getPrefixes(); const mainPrefix = prefixes[0]; const pluginName = "music"; const commandName = `${mainPrefix}${pluginName}`; +const pendingCleanupTimers = new Set>(); // ==================== Configuration ==================== const CONFIG = { @@ -401,6 +402,11 @@ class ConfigManager { return false; } } + + static cleanup(): void { + this.db = null; + this.initialized = false; + } } // ==================== HTTP Client ==================== @@ -1751,6 +1757,12 @@ class Downloader { // ==================== Main Plugin ==================== class MusicPlugin extends Plugin { cleanup(): void { + for (const timer of pendingCleanupTimers) { + clearTimeout(timer); + } + pendingCleanupTimers.clear(); + ConfigManager.cleanup(); + MusicPlugin.initialized = false; } private static initialized = false; @@ -2234,6 +2246,7 @@ ${apiKey ? "✅" : "⚪"} AI搜索: ${apiKey ? "已启用" : "未配置"} await statusMsg.delete(); const timer = setTimeout(() => { + pendingCleanupTimers.delete(timer); try { if ( downloadResult.audioPath && @@ -2251,6 +2264,7 @@ ${apiKey ? "✅" : "⚪"} AI搜索: ${apiKey ? "已启用" : "未配置"} console.log("[music] 清理临时文件失败:", error); } }, 5000); + pendingCleanupTimers.add(timer); if (timer.unref) timer.unref(); } catch (error: any) { if (statusMsg) { diff --git a/music_bot/music_bot.ts b/music_bot/music_bot.ts index 45430ce7..e1616cd5 100644 --- a/music_bot/music_bot.ts +++ b/music_bot/music_bot.ts @@ -211,7 +211,7 @@ function getRemarkFromMsg(msg: Api.Message | string, n: number): string { class MusicBotPlugin extends Plugin { cleanup(): void { - // 当前插件不持有需要在 reload 时额外释放的长期资源。 + botReady.clear(); } description: string = `\n多音源音乐搜索\n${help_text}`; diff --git a/plugins.json b/plugins.json index e874fc11..8154340b 100644 --- a/plugins.json +++ b/plugins.json @@ -23,14 +23,14 @@ "url": "https://github.com/TeleBoxOrg/TeleBox_Plugins/blob/main/annualreport/annualreport.ts?raw=true", "desc": "年度报告" }, - "atall": { - "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/atall/atall.ts?raw=true", - "desc": "一键艾特全部成员" - }, "atadmins": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/atadmins/atadmins.ts?raw=true", "desc": "一键艾特全部管理员" }, + "atall": { + "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/atall/atall.ts?raw=true", + "desc": "一键艾特全部成员" + }, "audio_to_voice": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/audio_to_voice/audio_to_voice.ts?raw=true", "desc": "音乐转音频" @@ -55,6 +55,10 @@ "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/banana/banana.ts?raw=true", "desc": "Nano-Banana 图像编辑" }, + "bgp": { + "url": "https://github.com/TiaraBasori/TeleBox_Plugins/blob/main/bgp/bgp.ts?raw=true", + "desc": "BGP路由图查询工具" + }, "bin": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/bin/bin.ts?raw=true", "desc": "卡头检测" diff --git a/shift/shift.ts b/shift/shift.ts index d86a1c51..7bae03da 100644 --- a/shift/shift.ts +++ b/shift/shift.ts @@ -540,6 +540,15 @@ const groupBuffers = new Map< } >(); +function cleanupGroupBuffers(): void { + for (const entry of groupBuffers.values()) { + if (entry.timer) { + clearTimeout(entry.timer); + } + } + groupBuffers.clear(); +} + function getGroupKey(message: any): string | null { const gid = (message as any).groupedId; const sid = getChatIdFromMessage(message); @@ -824,7 +833,7 @@ class BackupManager { let hasMore = true; let offsetId = task.lastMessageId || 0; - while (hasMore) { + while (hasMore && task.status === "running") { const batch = await client.getMessages(task.sourceId, { limit: batchSize, offsetId, @@ -836,6 +845,10 @@ class BackupManager { } for (const message of batch) { + if (task.status !== "running") { + break; + } + try { // 限流控制 await this.rateLimiter.throttle(); @@ -872,6 +885,14 @@ class BackupManager { } } } + + if (task.status !== "running") { + break; + } + } + + if (task.status !== "running") { + return; } // 完成备份 @@ -913,6 +934,16 @@ class BackupManager { static getBackupStatus(taskId: string): BackupTask | null { return this.tasks.get(taskId) || null; } + + static cleanup(): void { + for (const task of this.tasks.values()) { + if (task.status === "running" || task.status === "pending") { + task.status = "failed"; + } + } + this.tasks.clear(); + this.rateLimiter.reset(); + } } // ==================== 导入导出功能 ==================== @@ -969,7 +1000,17 @@ async function shiftMessageListener( } class ShiftPlugin extends Plugin { cleanup(): void { - // 当前插件不持有需要在 reload 时额外释放的长期资源。 + cleanupGroupBuffers(); + BackupManager.cleanup(); + ruleCache.clear(); + if (sqliteDb) { + sqliteDb.close(); + sqliteDb = null; + } + if (lowdb?.write) { + void lowdb.write().catch(() => {}); + } + lowdb = null; } description: string = `智能转发助手 - 自动转发消息到指定目标\n\n${help_text}`; diff --git a/ssh/ssh.ts b/ssh/ssh.ts index 14cb22d0..267f523e 100644 --- a/ssh/ssh.ts +++ b/ssh/ssh.ts @@ -288,6 +288,10 @@ const help_text = `🔐 SSH管理插件 ${mainPrefix}ssh keys export - 导出所有密钥到文件`; class SSHPlugin extends Plugin { + cleanup(): void { + ConfigManager.cleanup(); + } + description: string = `SSH管理和服务器配置\n\n${help_text}`; cmdHandlers = { @@ -1552,4 +1556,3 @@ ${keysContent}`; } export default new SSHPlugin(); - diff --git a/yt-dlp/yt-dlp.ts b/yt-dlp/yt-dlp.ts index d912ba1f..2e55c738 100644 --- a/yt-dlp/yt-dlp.ts +++ b/yt-dlp/yt-dlp.ts @@ -75,7 +75,7 @@ if (!fs.existsSync(path.dirname(GEMINI_CONFIG_DB_PATH))) { } class GeminiConfigManager { -    private static db: Database.Database; +    private static db: Database.Database | null = null;     private static initialized = false;     private static init(): void {         if (this.initialized) return; @@ -87,6 +87,7 @@ class GeminiConfigManager {     }     static get(key: string, defaultValue?: string): string {         this.init(); +        if (!this.db) return defaultValue || GEMINI_DEFAULT_CONFIG[key] || "";         try {             const row = this.db.prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined;             return row ? row.value : (defaultValue || GEMINI_DEFAULT_CONFIG[key] || ""); @@ -94,10 +95,21 @@ class GeminiConfigManager {     }     static set(key: string, value: string): void {         this.init(); +        if (!this.db) return;         try {             this.db.prepare(`INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`).run(key, value);         } catch (error) { console.error("[yt-dlp] 保存配置失败:", error); }     } + +    static cleanup(): void { +        if (this.db) { +            try { +                this.db.close(); +            } catch {} +        } +        this.db = null; +        this.initialized = false; +    } } class HttpClient { @@ -496,7 +508,7 @@ const yt = async (msg: Api.Message) => { class YtMusicPlugin extends Plugin { cleanup(): void { - // 当前插件不持有需要在 reload 时额外释放的长期资源。 + GeminiConfigManager.cleanup(); }     description: string = HELP_TEXT;