From 919f4f0acf68d42990cc2a5704438bb1d66338fb Mon Sep 17 00:00:00 2001 From: Hamster-Prime Date: Thu, 14 May 2026 19:52:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TPM=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=98=E6=9B=B4=E5=91=BD=E4=BB=A4=E5=90=8E=20TeleBo?= =?UTF-8?q?x=20=E6=9C=8D=E5=8A=A1=E4=B8=8D=E5=8F=AF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:执行 .tpm install / .tpm remove / .tpm update 等插件变更命令后, 服务返回 "Unload generation 1" 错误,插件命令不再响应,通常需要重启恢复。 根因: 1. TPM 在插件文件写入/删除后直接调用 loadPlugins() 做原地重载 2. loadPlugins() 内部的 unloadPluginsForRuntime() 会 abort 当前 generation context,但后续 setup/cleanup/handler 注册仍复用这个 已 aborted 的 context,导致 GenerationContext.runTask() 拒绝执行 修复: - pluginManager: runPluginCleanup 移除 runtime.context.runTask() 包裹, 避免 cleanup 在 context 已 abort 后被拒绝执行 - runtimeManager: reloadRuntime 移除 redundant 的 oldRuntime.context.abort() 调用(buildRuntime 已创建全新 context) - tpm: 所有 loadPlugins() 替换为 scheduleRuntimeReload()(延迟执行 reloadRuntime()),走完整的 runtime 生命周期重载,避免复用已 abort 的 generation 此修复由 AI (Ava / OpenClaw) 编写、测试并提交。 --- src/plugin/tpm.ts | 2848 +++++++++++++++++------------------ src/utils/pluginManager.ts | 1117 +++++++------- src/utils/runtimeManager.ts | 715 +++++---- 3 files changed, 2332 insertions(+), 2348 deletions(-) diff --git a/src/plugin/tpm.ts b/src/plugin/tpm.ts index 5750320..a474cbd 100644 --- a/src/plugin/tpm.ts +++ b/src/plugin/tpm.ts @@ -1,1429 +1,1419 @@ -import { Plugin, isValidPlugin } from "@utils/pluginBase"; -import { loadPlugins } from "@utils/pluginManager"; -import { - createDirectoryInTemp, - createDirectoryInAssets, -} from "@utils/pathHelpers"; -import path from "path"; -import fs from "fs"; -import axios from "axios"; -import { Api } from "teleproto"; -import { safeGetReplyMessage } from "@utils/safeGetMessages"; -import { JSONFilePreset } from "lowdb/node"; -import { getPrefixes } from "@utils/pluginManager"; -import { tryGetCurrentGenerationContext } from "@utils/globalClient"; - -const prefixes = getPrefixes(); -const mainPrefix = prefixes[0]; -const MAX_MESSAGE_LENGTH = 4000; -const PLUGINS_INDEX_URL = - "https://raw.githubusercontent.com/TeleBoxDev/TeleBox_Plugins/main/plugins.json"; -const REQUEST_TIMEOUT_MS = 20000; -const MAX_RETRIES = 4; -const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); -const DEFAULT_HEADERS = { - "User-Agent": "TeleBox-TPM/1.0", - Accept: "application/json, text/plain, */*", -}; - -interface PluginRecord { - url: string; - desc?: string; - _updatedAt: number; -} - -type Database = Record; -type RemotePluginInfo = { url: string; desc?: string }; -type RemotePluginsIndex = Record; - -const PLUGIN_PATH = path.join(process.cwd(), "plugins"); - -class EntityManager { - private count = 0; - private readonly LIMIT = 100; - private readonly IMPORTANT_TAGS = ['blockquote', 'a', 'b', 'i', 'u']; - - canAdd(tag: string): boolean { - if (this.IMPORTANT_TAGS.includes(tag)) { - return true; - } - return this.count < this.LIMIT; - } - - add(tag: string) { - this.count++; - } - - getCount(): number { - return this.count; - } - - hasReachedLimit(): boolean { - return this.count >= this.LIMIT; - } -} - -function htmlEscape(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function codeTag(value: string): string { - return `${htmlEscape(value)}`; -} - -async function sendOrEditMessage( - msg: Api.Message, - text: string, - options?: { parseMode?: string; linkPreview?: boolean } -): Promise { - const messageOptions = { - text, - parseMode: options?.parseMode || undefined, - linkPreview: options?.linkPreview !== false, - }; - - try { - await msg.edit(messageOptions); - return msg; - } catch (error) { - console.log(`[TPM] 编辑消息失败,尝试发送新消息: ${error}`); - } - - const sendOptions: any = { - message: text, - parseMode: options?.parseMode || undefined, - linkPreview: options?.linkPreview !== false, - }; - - if (msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId) { - sendOptions.replyTo = msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId; - } - - const newMsg = await msg.client?.sendMessage(msg.peerId, sendOptions); - return newMsg || msg; -} - -async function updateProgressMessage( - msg: Api.Message, - text: string, - options?: { parseMode?: string; linkPreview?: boolean } -): Promise { - const messageOptions = { - text, - parseMode: options?.parseMode || undefined, - linkPreview: options?.linkPreview !== false, - }; - - try { - await msg.edit(messageOptions); - return true; - } catch (error) { - console.log(`[TPM] 编辑进度消息失败,静默继续: ${error}`); - return false; - } -} - -function splitLongText(text: string, maxLength: number = MAX_MESSAGE_LENGTH): string[] { - if (text.length <= maxLength) { - return [text]; - } - - const messages: string[] = []; - const lines = text.split('\n'); - let currentMessage = ''; - - for (const line of lines) { - if (line.length > maxLength) { - if (currentMessage) { - messages.push(currentMessage); - currentMessage = ''; - } - for (let i = 0; i < line.length; i += maxLength) { - messages.push(line.substring(i, i + maxLength)); - } - continue; - } - - if (currentMessage.length + line.length + 1 > maxLength) { - messages.push(currentMessage); - currentMessage = line; - } else { - currentMessage += (currentMessage ? '\n' : '') + line; - } - } - - if (currentMessage) { - messages.push(currentMessage); - } - - return messages; -} - -async function sendLongMessage( - msg: Api.Message, - text: string, - options?: { parseMode?: string; linkPreview?: boolean }, - isEdit: boolean = true -): Promise { - const messages = splitLongText(text); - - if (messages.length === 0) { - return; - } - - const messageOptions = { - parseMode: options?.parseMode || undefined, - linkPreview: options?.linkPreview !== false, - }; - - if (isEdit) { - try { - await msg.edit({ - text: messages[0], - ...messageOptions, - }); - } catch (error) { - await msg.client?.sendMessage(msg.peerId, { - message: messages[0], - ...messageOptions, - replyTo: msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId, - }); - } - } else { - await msg.client?.sendMessage(msg.peerId, { - message: messages[0], - ...messageOptions, - replyTo: msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId, - }); - } - - for (let i = 1; i < messages.length; i++) { - await msg.reply({ - message: `📋 续 (${i}/${messages.length - 1}):\n\n
${messages[i]}
`, - ...messageOptions, - }); - } -} - -async function getDatabase() { - const filePath = path.join(createDirectoryInAssets("tpm"), "plugins.json"); - const db = await JSONFilePreset(filePath, {}); - return db; -} - -async function getMediaFileName(msg: any): Promise { - const metadata = msg.media as any; - return metadata.document.attributes[0].fileName; -} - -function normalizeGithubUrl(input: string): string { - try { - const parsed = new URL(input); - if (parsed.hostname === "github.com") { - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 5 && parts[2] === "blob") { - const [owner, repo, , branch, ...rest] = parts; - return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${rest.join("/")}`; - } - return input; - } - if (parsed.hostname === "raw.githubusercontent.com") { - parsed.search = ""; - return parsed.toString(); - } - return input; - } catch { - return input; - } -} - -function getRetryDelayMs(error: unknown, attempt: number): number { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - if (status === 429) { - const retryAfter = error.response?.headers?.["retry-after"]; - if (typeof retryAfter === "string") { - const seconds = Number.parseInt(retryAfter, 10); - if (!Number.isNaN(seconds)) { - return Math.max(0, seconds * 1000); - } - const date = Date.parse(retryAfter); - if (!Number.isNaN(date)) { - return Math.max(0, date - Date.now()); - } - } - } - } - const base = 600 * Math.pow(2, attempt); - const jitter = Math.floor(Math.random() * 250); - return base + jitter; -} - -async function lifecycleDelay(ms: number, label: string): Promise { - const lifecycle = tryGetCurrentGenerationContext(); - if (lifecycle) { - await lifecycle.delay(ms, { label }); - return; - } - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function fetchWithRetry( - url: string, - options?: Parameters[1] -) { - let lastError: unknown; - const normalizedUrl = normalizeGithubUrl(url); - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - return await axios.get(normalizedUrl, { - timeout: REQUEST_TIMEOUT_MS, - ...options, - headers: { - ...DEFAULT_HEADERS, - ...(options?.headers || {}), - }, - }); - } catch (error) { - lastError = error; - const status = axios.isAxiosError(error) ? error.response?.status : undefined; - if (!status || !RETRYABLE_STATUS.has(status) || attempt === MAX_RETRIES) { - throw error; - } - const delay = getRetryDelayMs(error, attempt); - await lifecycleDelay(delay, "tpm:fetch-retry"); - } - } - throw lastError; -} - -async function installRemotePlugin(plugin: string, msg: Api.Message) { - const statusMsg = await sendOrEditMessage(msg, `正在安装插件 ${plugin}...`); - const res = await fetchWithRetry(PLUGINS_INDEX_URL); - if (res.status === 200) { - if (!res.data[plugin]) { - await sendOrEditMessage(statusMsg, `未找到插件 ${plugin} 的远程资源`); - return; - } - const pluginUrl = normalizeGithubUrl(res.data[plugin].url); - const response = await fetchWithRetry(pluginUrl, { - responseType: "text", - }); - if (response.status !== 200) { - await sendOrEditMessage(statusMsg, `无法下载插件 ${plugin}`); - return; - } - const filePath = path.join(PLUGIN_PATH, `${plugin}.ts`); - const oldBackupPath = path.join(PLUGIN_PATH, `${plugin}.ts.backup`); - - if (fs.existsSync(filePath)) { - const cacheDir = createDirectoryInTemp("plugin_backups"); - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - const backupPath = path.join(cacheDir, `${plugin}_${timestamp}.ts.bak`); - fs.copyFileSync(filePath, backupPath); - console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); - } - - if (fs.existsSync(oldBackupPath)) { - fs.unlinkSync(oldBackupPath); - console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); - } - - fs.writeFileSync(filePath, response.data); - - try { - const db = await getDatabase(); - db.data[plugin] = { ...res.data[plugin], _updatedAt: Date.now() }; - await db.write(); - console.log(`[TPM] 已记录插件信息到数据库: ${plugin}`); - } catch (error) { - console.error(`[TPM] 记录插件信息失败: ${error}`); - } - - await sendOrEditMessage(statusMsg, `插件 ${plugin} 已安装并加载成功`); - await loadPlugins(); - } else { - await sendOrEditMessage(statusMsg, `无法获取远程插件库`); - } -} - -async function installAllPlugins(msg: Api.Message) { - const statusMsg = await sendOrEditMessage(msg, "🔍 正在获取远程插件列表..."); - try { - const res = await fetchWithRetry(PLUGINS_INDEX_URL); - if (res.status !== 200) { - await sendOrEditMessage(statusMsg, "❌ 无法获取远程插件库"); - return; - } - - const plugins = Object.keys(res.data); - const totalPlugins = plugins.length; - if (totalPlugins === 0) { - await sendOrEditMessage(statusMsg, "📦 远程插件库为空"); - return; - } - - let installedCount = 0; - let failedCount = 0; - const failedPlugins: string[] = []; - - await sendOrEditMessage(statusMsg, `📦 开始安装 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); - - for (let i = 0; i < plugins.length; i++) { - const plugin = plugins[i]; - const progress = Math.round(((i + 1) / totalPlugins) * 100); - const progressBar = htmlEscape(generateProgressBar(progress)); - try { - if ([0, plugins.length - 1].includes(i) || i % 2 === 0) { - await sendOrEditMessage(statusMsg, `📦 正在安装插件: ${codeTag(plugin)}\n\n${progressBar}\n🔄 进度: ${ - i + 1 - }/${totalPlugins} (${progress}%)\n✅ 成功: ${installedCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); - } - - const pluginData = res.data[plugin]; - if (!pluginData || !pluginData.url) { - failedCount++; - failedPlugins.push(`${plugin} (无URL)`); - continue; - } - - const pluginUrl = normalizeGithubUrl(pluginData.url); - const response = await fetchWithRetry(pluginUrl, { - responseType: "text", - }); - if (response.status !== 200) { - failedCount++; - failedPlugins.push(`${plugin} (下载失败)`); - continue; - } - - const filePath = path.join(PLUGIN_PATH, `${plugin}.ts`); - const oldBackupPath = path.join(PLUGIN_PATH, `${plugin}.ts.backup`); - - if (fs.existsSync(filePath)) { - const cacheDir = createDirectoryInTemp("plugin_backups"); - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - const backupPath = path.join(cacheDir, `${plugin}_${timestamp}.ts.bak`); - fs.copyFileSync(filePath, backupPath); - console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); - } - if (fs.existsSync(oldBackupPath)) { - fs.unlinkSync(oldBackupPath); - console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); - } - - fs.writeFileSync(filePath, response.data); - - try { - const db = await getDatabase(); - db.data[plugin] = { - url: pluginUrl, - desc: pluginData.desc, - _updatedAt: Date.now(), - }; - await db.write(); - console.log(`[TPM] 已记录插件信息到数据库: ${plugin}`); - } catch (dbError) { - console.error(`[TPM] 记录插件信息失败: ${dbError}`); - } - - installedCount++; - await lifecycleDelay(100, "tpm:batch-install-throttle"); - } catch (error) { - failedCount++; - failedPlugins.push(`${plugin} (${htmlEscape(String(error))})`); - console.error(`[TPM] 安装插件 ${plugin} 失败:`, error); - } - } - - try { - await loadPlugins(); - } catch (error) { - console.error("[TPM] 重新加载插件失败:", error); - } - - const successBar = generateProgressBar(100); - let resultMsg = `🎉 批量安装完成!\n\n${successBar}\n\n📊 安装统计:\n✅ 成功安装: ${installedCount}/${totalPlugins}\n❌ 安装失败: ${failedCount}/${totalPlugins}`; - if (failedPlugins.length > 0) { - const failedList = failedPlugins.slice(0, 5).map(htmlEscape).join("\n• "); - const moreFailures = - failedPlugins.length > 5 - ? `\n• ... 还有 ${failedPlugins.length - 5} 个失败` - : ""; - resultMsg += `\n\n❌ 失败列表:\n• ${failedList}${moreFailures}`; - } - resultMsg += `\n\n🔄 插件已重新加载,可以开始使用!`; - - await sendOrEditMessage(statusMsg, resultMsg, { parseMode: "html" }); - } catch (error) { - await sendOrEditMessage(statusMsg, `❌ 批量安装失败: ${error}`); - console.error("[TPM] 批量安装插件失败:", error); - } -} - -async function installMultiplePlugins(pluginNames: string[], msg: Api.Message) { - const totalPlugins = pluginNames.length; - if (totalPlugins === 0) { - await sendOrEditMessage(msg, "❌ 未提供要安装的插件名称"); - return; - } - - const statusMsg = await sendOrEditMessage(msg, `🔍 正在获取远程插件列表...`, { parseMode: "html" }); - - try { - const res = await fetchWithRetry(PLUGINS_INDEX_URL); - if (res.status !== 200) { - await sendOrEditMessage(statusMsg, "❌ 无法获取远程插件库"); - return; - } - - let installedCount = 0; - let failedCount = 0; - const failedPlugins: string[] = []; - const notFoundPlugins: string[] = []; - - await sendOrEditMessage(statusMsg, `📦 开始安装 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); - - for (let i = 0; i < pluginNames.length; i++) { - const pluginName = pluginNames[i]; - const progress = Math.round(((i + 1) / totalPlugins) * 100); - const progressBar = htmlEscape(generateProgressBar(progress)); - - try { - if ([0, pluginNames.length - 1].includes(i) || i % 2 === 0) { - await sendOrEditMessage(statusMsg, `📦 正在安装插件: ${codeTag(pluginName)}\n\n${progressBar}\n🔄 进度: ${ - i + 1 - }/${totalPlugins} (${progress}%)\n✅ 成功: ${installedCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); - } - - if (!res.data[pluginName]) { - failedCount++; - notFoundPlugins.push(pluginName); - continue; - } - - const pluginData = res.data[pluginName]; - if (!pluginData.url) { - failedCount++; - failedPlugins.push(`${pluginName} (无URL)`); - continue; - } - - const pluginUrl = normalizeGithubUrl(pluginData.url); - const response = await fetchWithRetry(pluginUrl, { - responseType: "text", - }); - if (response.status !== 200) { - failedCount++; - failedPlugins.push(`${pluginName} (下载失败)`); - continue; - } - - const filePath = path.join(PLUGIN_PATH, `${pluginName}.ts`); - const oldBackupPath = path.join(PLUGIN_PATH, `${pluginName}.ts.backup`); - - if (fs.existsSync(filePath)) { - const cacheDir = createDirectoryInTemp("plugin_backups"); - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - const backupPath = path.join( - cacheDir, - `${pluginName}_${timestamp}.ts` - ); - fs.copyFileSync(filePath, backupPath); - console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); - } - - if (fs.existsSync(oldBackupPath)) { - fs.unlinkSync(oldBackupPath); - console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); - } - - fs.writeFileSync(filePath, response.data); - - try { - const db = await getDatabase(); - db.data[pluginName] = { - url: pluginUrl, - desc: pluginData.desc, - _updatedAt: Date.now(), - }; - await db.write(); - console.log(`[TPM] 已记录插件信息到数据库: ${pluginName}`); - } catch (dbError) { - console.error(`[TPM] 记录插件信息失败: ${dbError}`); - } - - installedCount++; - await lifecycleDelay(100, "tpm:batch-install-throttle"); - } catch (error) { - failedCount++; - failedPlugins.push(`${pluginName} (${htmlEscape(String(error))})`); - console.error(`[TPM] 安装插件 ${pluginName} 失败:`, error); - } - } - - try { - await loadPlugins(); - } catch (error) { - console.error("[TPM] 重新加载插件失败:", error); - } - - const successBar = generateProgressBar(100); - let resultMsg = `🎉 批量安装完成!\n\n${successBar}\n\n📊 安装统计:\n✅ 成功安装: ${installedCount}/${totalPlugins}\n❌ 安装失败: ${failedCount}/${totalPlugins}`; - - if (notFoundPlugins.length > 0) { - const notFoundList = notFoundPlugins.slice(0, 5).map(htmlEscape).join("\n• "); - const moreNotFound = - notFoundPlugins.length > 5 - ? `\n• ... 还有 ${notFoundPlugins.length - 5} 个未找到` - : ""; - resultMsg += `\n\n🔍 未找到的插件:\n• ${notFoundList}${moreNotFound}`; - } - - if (failedPlugins.length > 0) { - const failedList = failedPlugins.slice(0, 5).map(htmlEscape).join("\n• "); - const moreFailures = - failedPlugins.length > 5 - ? `\n• ... 还有 ${failedPlugins.length - 5} 个失败` - : ""; - resultMsg += `\n\n❌ 其他失败:\n• ${failedList}${moreFailures}`; - } - - resultMsg += `\n\n🔄 插件已重新加载,可以开始使用!`; - - await sendOrEditMessage(statusMsg, resultMsg, { parseMode: "html" }); - } catch (error) { - await sendOrEditMessage(statusMsg, `❌ 批量安装失败: ${error}`); - console.error("[TPM] 批量安装插件失败:", error); - } -} - -function generateProgressBar(percentage: number, length: number = 20): string { - const filled = Math.round((percentage / 100) * length); - const empty = length - filled; - const bar = "█".repeat(filled) + "░".repeat(empty); - return `🔄 当前进度: [${bar}] ${percentage}%`; -} - -async function installPlugin(args: string[], msg: Api.Message) { - if (args.length === 1) { - if (msg.isReply) { - const replied = await safeGetReplyMessage(msg); - if (replied?.media) { - const fileName = await getMediaFileName(replied); - - if (!fileName.endsWith(".ts")) { - await sendOrEditMessage(msg, `❌ 文件格式错误\n文件不是有效插件`); - return; - } - - const pluginName = fileName.replace(".ts", ""); - const statusMsg = await sendOrEditMessage(msg, `🔍 正在验证插件 ${pluginName} ...`); - const filePath = path.join(PLUGIN_PATH, fileName); - - await msg.client?.downloadMedia(replied, { outputFile: filePath }); - - try { - const pluginModule = require(filePath); - const pluginInstance = pluginModule.default || pluginModule; - - if (!isValidPlugin(pluginInstance)) { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - await sendOrEditMessage(statusMsg, `❌ 插件验证失败\n文件不是有效插件`); - return; - } - } catch (error) { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - await sendOrEditMessage(statusMsg, `❌ 插件加载失败\n错误信息:\n${error instanceof Error ? error.message : String(error)}`); - return; - } - - await sendOrEditMessage(statusMsg, `✅ 验证通过,正在安装插件 ${pluginName} ...`); - - let overrideMessage = ""; - try { - const db = await getDatabase(); - if (db.data[pluginName]) { - delete db.data[pluginName]; - await db.write(); - overrideMessage = `\n⚠️ 已覆盖之前已安装的远程插件\n若需保持更新, 请 ${codeTag(`${mainPrefix}tpm i ${pluginName}`)}`; - console.log(`[TPM] 已从数据库中清除同名插件记录: ${pluginName}`); - } - } catch (error) { - console.error(`[TPM] 清除数据库记录失败: ${error}`); - } - - await loadPlugins(); - await sendOrEditMessage(statusMsg, `✅ 插件 ${htmlEscape(pluginName)} 已安装并加载成功${overrideMessage}`, { parseMode: "html" }); - } else { - await sendOrEditMessage(msg, "请回复一个插件文件"); - } - } else { - await sendOrEditMessage(msg, "请回复某个插件文件或提供 tpm 包名"); - } - } else { - const pluginNames = args.slice(1); - - if (pluginNames.length === 1 && pluginNames[0] === "all") { - await installAllPlugins(msg); - } else if (pluginNames.length === 1) { - await installRemotePlugin(pluginNames[0], msg); - } else { - await installMultiplePlugins(pluginNames, msg); - } - } -} - -async function uninstallPlugin(plugin: string, msg: Api.Message) { - if (!plugin) { - await sendOrEditMessage(msg, "请提供要卸载的插件名称"); - return; - } - const statusMsg = await sendOrEditMessage(msg, `正在卸载插件 ${plugin}...`); - const pluginPath = path.join(PLUGIN_PATH, `${plugin}.ts`); - if (fs.existsSync(pluginPath)) { - fs.unlinkSync(pluginPath); - try { - const db = await getDatabase(); - if (db.data[plugin]) { - delete db.data[plugin]; - await db.write(); - console.log(`[TPM] 已从数据库中删除插件记录: ${plugin}`); - } - } catch (error) { - console.error(`[TPM] 删除插件数据库记录失败: ${error}`); - } - await sendOrEditMessage(statusMsg, `插件 ${plugin} 已卸载`); - } else { - await sendOrEditMessage(statusMsg, `未找到插件 ${plugin}`); - } - await loadPlugins(); -} - -async function uninstallMultiplePlugins( - pluginNames: string[], - msg: Api.Message -) { - if (!pluginNames || pluginNames.length === 0) { - await sendOrEditMessage(msg, "请提供要卸载的插件名称"); - return; - } - - const results: { name: string; success: boolean; reason?: string }[] = []; - let processedCount = 0; - const totalCount = pluginNames.length; - - const statusMsg = await sendOrEditMessage(msg, `开始卸载 ${totalCount} 个插件...\n${generateProgressBar( - 0 - )} 0/${totalCount}`); - - try { - const db = await getDatabase(); - - for (const pluginName of pluginNames) { - const trimmedName = pluginName.trim(); - if (!trimmedName) { - results.push({ - name: pluginName, - success: false, - reason: "插件名称为空", - }); - processedCount++; - continue; - } - - const pluginPath = path.join(PLUGIN_PATH, `${trimmedName}.ts`); - - if (fs.existsSync(pluginPath)) { - try { - fs.unlinkSync(pluginPath); - if (db.data[trimmedName]) { - delete db.data[trimmedName]; - console.log(`[TPM] 已从数据库中删除插件记录: ${trimmedName}`); - } - results.push({ name: trimmedName, success: true }); - } catch (error) { - console.error(`[TPM] 卸载插件 ${trimmedName} 失败:`, error); - results.push({ - name: trimmedName, - success: false, - reason: `删除失败: ${ - error instanceof Error ? error.message : String(error) - }`, - }); - } - } else { - results.push({ - name: trimmedName, - success: false, - reason: "插件不存在", - }); - } - - processedCount++; - const percentage = Math.round((processedCount / totalCount) * 100); - - await sendOrEditMessage(statusMsg, `卸载插件中...\n${generateProgressBar( - percentage - )} ${processedCount}/${totalCount}\n当前: ${trimmedName}`); - } - - await db.write(); - } catch (error) { - console.error(`[TPM] 批量卸载过程中发生错误:`, error); - await sendOrEditMessage(msg, `批量卸载过程中发生错误: ${ - error instanceof Error ? error.message : String(error) - }`); - return; - } - - await loadPlugins(); - - const successCount = results.filter((r) => r.success).length; - const failedCount = results.filter((r) => !r.success).length; - - let resultText = `\n📊 卸载完成\n\n`; - resultText += `✅ 成功: ${successCount}\n`; - resultText += `❌ 失败: ${failedCount}\n\n`; - - if (successCount > 0) { - const successPlugins = results.filter((r) => r.success).map((r) => r.name); - resultText += `✅ 已卸载:\n${successPlugins - .map((name) => ` • ${name}`) - .join("\n")}\n\n`; - } - - if (failedCount > 0) { - const failedPlugins = results.filter((r) => !r.success); - resultText += `❌ 卸载失败:\n${failedPlugins - .map((r) => ` • ${r.name}: ${r.reason}`) - .join("\n")}`; - } - - await sendOrEditMessage(statusMsg, resultText); -} - -async function uninstallAllPlugins(msg: Api.Message) { - try { - const statusMsg = await sendOrEditMessage(msg, "⚠️ 正在清空插件目录并刷新缓存..."); - - let removed = 0; - let failed: string[] = []; - - try { - if (fs.existsSync(PLUGIN_PATH)) { - const files = fs.readdirSync(PLUGIN_PATH); - for (const file of files) { - const full = path.join(PLUGIN_PATH, file); - const isPluginTs = - file.endsWith(".ts") && - !file.includes("backup") && - !file.endsWith(".d.ts") && - !file.startsWith("_"); - if (!isPluginTs) continue; - try { - fs.unlinkSync(full); - removed++; - } catch (e) { - failed.push(file); - } - } - } - } catch (e) { - console.error("[TPM] 扫描插件目录失败:", e); - } - - try { - const db = await getDatabase(); - for (const k of Object.keys(db.data)) delete db.data[k]; - await db.write(); - } catch (e) { - console.error("[TPM] 清空数据库失败:", e); - } - - try { - await loadPlugins(); - } catch (e) { - console.error("[TPM] 重新加载插件失败:", e); - } - - let text = `✅ 已清空插件目录并刷新缓存\n\n🗑 删除文件: ${removed}`; - if (failed.length) { - const show = failed.slice(0, 10).map(htmlEscape).join("\n• "); - text += `\n❌ 删除失败: ${failed.length}\n• ${show}${ - failed.length > 10 ? `\n• ... 还有 ${failed.length - 10} 个失败` : "" - }`; - } - await sendOrEditMessage(statusMsg, text, { parseMode: "html" }); - } catch (error) { - console.error("[TPM] 清空插件目录失败:", error); - await sendOrEditMessage(msg, `❌ 清空插件目录失败: ${error}`); - } -} - -async function uploadPlugin(args: string[], msg: Api.Message) { - const pluginName = args[1]; - if (!pluginName) { - await sendOrEditMessage(msg, "请提供插件名称"); - return; - } - const pluginPath = path.join(PLUGIN_PATH, `${pluginName}.ts`); - if (!fs.existsSync(pluginPath)) { - await sendOrEditMessage(msg, `未找到插件 ${pluginName}`); - return; - } - - const statusMsg = await sendOrEditMessage(msg, `正在上传插件 ${pluginName}...`); - - const sendOptions: any = { - file: pluginPath, - thumb: path.join(process.cwd(), "telebox.png"), - caption: `**TeleBox_Plugin ${pluginName} plugin.**`, - }; - - if (msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId) { - sendOptions.replyTo = msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId; - } - - await msg.client?.sendFile(msg.peerId, sendOptions); - - if (statusMsg.id !== msg.id) { - await statusMsg.delete(); - } else { - await msg.delete(); - } -} - -async function search(msg: Api.Message) { - const text = msg.message; - const parts = text.trim().split(/\s+/); - const keyword = parts.length > 2 ? parts[2].toLowerCase() : ""; - - try { - const statusMsg = await sendOrEditMessage(msg, keyword ? `🔍 正在搜索插件: ${keyword}` : "🔍 正在获取插件列表..."); - const res = await fetchWithRetry(PLUGINS_INDEX_URL, { - headers: { - "Cache-Control": "no-cache", - Pragma: "no-cache", - }, - }); - if (res.status !== 200) { - await sendOrEditMessage(statusMsg, `❌ 无法获取远程插件库`); - return; - } - const remotePlugins = res.data; - const pluginNames = Object.keys(remotePlugins); - - const localPlugins = new Set(); - try { - if (fs.existsSync(PLUGIN_PATH)) { - const files = fs.readdirSync(PLUGIN_PATH); - files.forEach((file) => { - if (file.endsWith(".ts") && !file.includes("backup")) { - localPlugins.add(file.replace(".ts", "")); - } - }); - } - } catch (error) { - console.error("[TPM] 读取本地插件失败:", error); - } - - const db = await getDatabase(); - const dbPlugins = db.data; - - const filteredPlugins = keyword - ? pluginNames.filter(name => { - const pluginData = remotePlugins[name]; - const nameMatch = name.toLowerCase().includes(keyword); - const descMatch = pluginData?.desc?.toLowerCase().includes(keyword) || false; - return nameMatch || descMatch; - }) - : pluginNames; - - const totalPlugins = filteredPlugins.length; - - if (totalPlugins === 0 && keyword) { - await sendOrEditMessage(statusMsg, `🔍 未找到包含 "${htmlEscape(keyword)}" 的插件`, { parseMode: "html" }); - return; - } - - let installedCount = 0; - let localOnlyCount = 0; - let notInstalledCount = 0; - - const entityMgr = new EntityManager(); - - // 预留重要标签的位置 - entityMgr.add('b'); // 标题 - entityMgr.add('b'); // 统计标题 - entityMgr.add('b'); // 搜索关键词 - entityMgr.add('b'); // 搜索结果标题 - entityMgr.add('blockquote'); // 插件列表 - entityMgr.add('b'); // 快捷操作标题 - entityMgr.add('code'); // 第一个命令 - entityMgr.add('code'); // 第二个命令 - entityMgr.add('code'); // 第三个命令 - entityMgr.add('code'); // 第四个命令 - entityMgr.add('code'); // 第五个命令 - entityMgr.add('code'); // 第六个命令 - entityMgr.add('b'); // 仓库标题 - - const highlightMatch = (text: string) => { - const escapedText = htmlEscape(text); - if (!keyword) return escapedText; - const escapedKeyword = htmlEscape(keyword); - const regex = new RegExp(`(${escapedKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); - return escapedText.replace(regex, '$1'); - }; - - function getPluginStatus(pluginName: string) { - const hasLocal = localPlugins.has(pluginName); - const dbRecord = dbPlugins[pluginName]; - - if (hasLocal && dbRecord) { - installedCount++; - return { status: "✅", label: "已安装" } as const; - } else if (hasLocal && !dbRecord) { - localOnlyCount++; - return { status: "🔶", label: "本地同名" } as const; - } else { - notInstalledCount++; - return { status: "❌", label: "未安装" } as const; - } - } - - const pluginLines: string[] = []; - for (const plugin of filteredPlugins) { - const pluginData = remotePlugins[plugin]; - const { status } = getPluginStatus(plugin); - const description = pluginData?.desc || "暂无描述"; - - const highlightedName = highlightMatch(plugin); - const highlightedDesc = highlightMatch(description); - - const allowCodeTag = entityMgr.canAdd('code'); - const nameTag = allowCodeTag && !keyword ? codeTag(plugin) : highlightedName; - - pluginLines.push(`${status} ${nameTag} - ${highlightedDesc}`); - - if (allowCodeTag) { - entityMgr.add('code'); - } - - if (keyword) { - entityMgr.add('b'); - } - } - - let statsInfo = `📊 插件统计:\n`; - if (keyword) { - statsInfo += `• 搜索关键词: "${htmlEscape(keyword)}"\n`; - } - statsInfo += `• 总计: ${totalPlugins} 个插件\n`; - statsInfo += `• ✅ 已安装: ${installedCount} 个\n`; - statsInfo += `• 🔶 本地同名: ${localOnlyCount} 个\n`; - statsInfo += `• ❌ 未安装: ${notInstalledCount} 个`; - - const installTip = `\n💡 快捷操作:\n` + - `• ${mainPrefix}tpm i [名称1] [名称2 ...] 安装/批量安装\n` + - `• ${mainPrefix}tpm i all 全部安装\n` + - `• ${mainPrefix}tpm update 更新已装\n` + - `• ${mainPrefix}tpm ls 查看记录\n` + - `• ${mainPrefix}tpm rm [名称] 卸载\n` + - `• ${mainPrefix}tpm rm all 清空`; - - const repoLink = `\n🔗 插件仓库: TeleBox_Plugins`; - - const title = keyword ? `🔍 搜索 "${htmlEscape(keyword)}" 结果` : `🔍 远程插件列表`; - const fullMessage = [ - `${title}`, - `━━━━━━━━━━━━━━━━━`, - "", - statsInfo, - "", - keyword ? `📦 搜索结果:` : `📦 插件详情:`, - `
${pluginLines.join("\n")}
`, - installTip, - repoLink - ].join("\n"); - - await sendLongMessage(statusMsg, fullMessage, { parseMode: "html", linkPreview: false }, true); - } catch (error) { - console.error("[TPM] 搜索插件失败:", error); - await sendOrEditMessage(msg, `❌ 搜索插件失败: ${error}`); - } -} - -async function showPluginRecords(msg: Api.Message, verbose?: boolean) { - try { - const statusMsg = await sendOrEditMessage(msg, "📚 正在读取插件数据..."); - const db = await getDatabase(); - const dbNames = Object.keys(db.data); - - let filePlugins: string[] = []; - try { - if (fs.existsSync(PLUGIN_PATH)) { - filePlugins = fs - .readdirSync(PLUGIN_PATH) - .filter( - (f) => - f.endsWith(".ts") && - !f.includes("backup") && - !f.endsWith(".d.ts") && - !f.startsWith("_") - ) - .map((f) => f.replace(/\.ts$/, "")); - } - } catch (err) { - console.error("[TPM] 读取本地插件目录失败:", err); - } - - const notInDb = filePlugins.filter((n) => !dbNames.includes(n)); - - const sortedPlugins = dbNames - .map((name) => ({ name, ...db.data[name] })) - .sort((a, b) => a._updatedAt - b._updatedAt); - - const entityMgr = new EntityManager(); - - entityMgr.add('b'); - entityMgr.add('b'); - entityMgr.add('b'); - entityMgr.add('b'); - entityMgr.add('b'); - entityMgr.add('blockquote'); - entityMgr.add('blockquote'); - - const dbLinesSimple: string[] = []; - const dbLinesVerbose: string[] = []; - - for (const p of sortedPlugins) { - const allowCodeTag = entityMgr.canAdd('code'); - - if (verbose) { - const updateTime = new Date(p._updatedAt).toLocaleString("zh-CN"); - const desc = p.desc ? `\n📝 ${htmlEscape(p.desc)}` : ""; - const nameTag = allowCodeTag ? codeTag(p.name) : htmlEscape(p.name); - const urlTag = allowCodeTag ? codeTag(p.url) : htmlEscape(p.url); - dbLinesVerbose.push(`${nameTag} 🕒 ${updateTime}${desc}\n🔗 ${urlTag}`); - - if (allowCodeTag) { - entityMgr.add('code'); - entityMgr.add('code'); - } - } else { - const nameTag = allowCodeTag ? codeTag(p.name) : htmlEscape(p.name); - dbLinesSimple.push(`${nameTag}${p.desc ? ` - ${htmlEscape(p.desc)}` : ""}`); - - if (allowCodeTag) { - entityMgr.add('code'); - } - } - } - - const localLinesSimple: string[] = []; - const localLinesVerbose: string[] = []; - - for (const name of notInDb) { - const allowCodeTag = entityMgr.canAdd('code'); - const nameTag = allowCodeTag ? codeTag(name) : htmlEscape(name); - - if (verbose) { - const filePath = path.join(PLUGIN_PATH, `${name}.ts`); - let mtime = "未知"; - try { - const stat = fs.statSync(filePath); - mtime = stat.mtime.toLocaleString("zh-CN"); - } catch {} - localLinesVerbose.push(`${nameTag} 🗄 ${mtime}`); - } else { - localLinesSimple.push(nameTag); - } - - if (allowCodeTag) { - entityMgr.add('code'); - } - } - - const tip = verbose - ? "" - : `💡 可使用 ${mainPrefix}tpm ls -v 查看详情信息`; - - const dbLines = verbose ? dbLinesVerbose : dbLinesSimple; - const localLines = verbose ? localLinesVerbose : localLinesSimple; - - const messageParts: string[] = []; - - messageParts.push(`📚 插件记录`); - messageParts.push(`━━━━━━━━━━━━━━━━━`); - - if (tip) { - messageParts.push("", tip); - entityMgr.add('code'); - } - - if (dbNames.length > 0) { - messageParts.push("", `📦 远程插件记录 (${dbNames.length}个):`); - messageParts.push(`
${dbLines.join("\n")}
`); - } else { - messageParts.push("", `📦 远程插件记录: (空)`); - } - - if (notInDb.length > 0) { - messageParts.push("", `🗂 本地插件 (${notInDb.length}个):`); - messageParts.push(`
${localLines.join("\n")}
`); - } - - messageParts.push("", `━━━━━━━━━━━━━━━━━`); - messageParts.push(`📊 总计: ${dbNames.length + notInDb.length} 个插件`); - - const fullMessage = messageParts.join("\n"); - - await sendLongMessage(statusMsg, fullMessage, { parseMode: "html", linkPreview: false }, true); - } catch (error) { - console.error("[TPM] 读取插件数据库失败:", error); - await sendOrEditMessage(msg, `❌ 读取数据库失败: ${error}`); - } -} - -async function updateAllPlugins(msg: Api.Message) { - const statusMsg = await sendOrEditMessage(msg, "🔍 正在检查待更新的插件..."); - let canEdit = true; - - try { - const db = await getDatabase(); - const dbPlugins = Object.keys(db.data); - - if (dbPlugins.length === 0) { - await sendOrEditMessage(statusMsg, "📦 数据库中没有已安装的插件记录"); - return; - } - - const totalPlugins = dbPlugins.length; - let updatedCount = 0; - let failedCount = 0; - let skipCount = 0; - const failedPlugins: string[] = []; - - if (canEdit) { - canEdit = await updateProgressMessage(statusMsg, `📦 开始更新 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); - } - - for (let i = 0; i < dbPlugins.length; i++) { - const pluginName = dbPlugins[i]; - const pluginRecord = db.data[pluginName]; - const progress = Math.round(((i + 1) / totalPlugins) * 100); - const progressBar = htmlEscape(generateProgressBar(progress)); - - try { - if (canEdit && ([0, dbPlugins.length - 1].includes(i) || i % 2 === 0)) { - canEdit = await updateProgressMessage(statusMsg, `📦 正在更新插件: ${codeTag(pluginName)}\n\n${progressBar}\n🔄 进度: ${ - i + 1 - }/${totalPlugins} (${progress}%)\n✅ 成功: ${updatedCount}\n⏭️ 跳过: ${skipCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); - } - - if (!pluginRecord.url) { - skipCount++; - console.log(`[TPM] 跳过更新插件 ${pluginName}: 无URL记录`); - continue; - } - - const response = await fetchWithRetry( - normalizeGithubUrl(pluginRecord.url), - { responseType: "text" } - ); - if (response.status !== 200) { - failedCount++; - failedPlugins.push(`${pluginName} (下载失败)`); - continue; - } - - const filePath = path.join(PLUGIN_PATH, `${pluginName}.ts`); - - if (!fs.existsSync(filePath)) { - skipCount++; - console.log(`[TPM] 跳过更新插件 ${pluginName}: 本地文件不存在`); - continue; - } - - const currentContent = fs.readFileSync(filePath, "utf8"); - if (currentContent === response.data) { - skipCount++; - console.log(`[TPM] 跳过更新插件 ${pluginName}: 内容无变化`); - continue; - } - - const cacheDir = createDirectoryInTemp("plugin_backups"); - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - const backupPath = path.join(cacheDir, `${pluginName}_${timestamp}.ts`); - fs.copyFileSync(filePath, backupPath); - console.log(`[TPM] 旧版本已备份到: ${backupPath}`); - - fs.writeFileSync(filePath, response.data); - - try { - db.data[pluginName]._updatedAt = Date.now(); - await db.write(); - console.log(`[TPM] 已更新插件数据库记录: ${pluginName}`); - } catch (dbError) { - console.error(`[TPM] 更新插件数据库记录失败: ${dbError}`); - } - - updatedCount++; - await lifecycleDelay(100, "tpm:update-throttle"); - } catch (error) { - failedCount++; - failedPlugins.push(`${pluginName} (${htmlEscape(String(error))})`); - console.error(`[TPM] 更新插件 ${pluginName} 失败:`, error); - } - } - - try { - await loadPlugins(); - } catch (error) { - console.error("[TPM] 重新加载插件失败:", error); - } - - try { - await statusMsg.delete(); - console.log(`[TPM] 更新完成。统计: 成功${updatedCount}个, 跳过${skipCount}个, 失败${failedCount}个`); - } catch (error) { - console.log(`[TPM] 删除状态消息失败: ${error}`); - try { - await statusMsg.edit({ - text: `✅ 更新完成 (成功${updatedCount}个, 跳过${skipCount}个, 失败${failedCount}个)`, - parseMode: "html" - }); - } catch (editError) { - console.log(`[TPM] 最终编辑也失败: ${editError}`); - } - } - } catch (error) { - console.error("[TPM] 一键更新失败:", error); - try { - await statusMsg.delete(); - } catch (deleteError) { - try { - await statusMsg.edit({ text: `❌ 一键更新失败: ${htmlEscape(String(error))}`, parseMode: "html" }); - } catch (editError) { - console.log(`[TPM] 错误消息编辑失败: ${editError}`); - } - } - } -} - -class TpmPlugin extends Plugin { - - description: string = `📦 TeleBox 插件管理器 (TPM) - -🔍 查看插件: -• ${mainPrefix}tpm search (别名: s) - 显示远程插件列表 -• ${mainPrefix}tpm ls (别名: list) - 查看已安装记录 -• ${mainPrefix}tpm ls -v${mainPrefix}tpm lv - 查看详细记录 - -⬇️ 安装插件: -• ${mainPrefix}tpm i [插件名] (别名: install) - 安装单个插件 -• ${mainPrefix}tpm i [插件名1] [插件名2] - 安装多个插件 -• ${mainPrefix}tpm i all - 一键安装全部远程插件 -• ${mainPrefix}tpm i (回复插件文件) - 安装本地插件文件 - -🔄 更新插件: -• ${mainPrefix}tpm update (别名: updateAll, ua) - 一键更新所有已安装的远程插件 - -🗑️ 卸载插件: -• ${mainPrefix}tpm rm [插件名] (别名: remove, uninstall, un) - 卸载单个插件 -• ${mainPrefix}tpm rm [插件名1] [插件名2] - 卸载多个插件 -• ${mainPrefix}tpm rm all - 清空插件目录并刷新本地缓存 - -⬆️ 上传插件: -• ${mainPrefix}tpm upload [插件名] (别名: ul) - 上传指定插件文件`; - - ignoreEdited: boolean = true; - - cmdHandlers: Record Promise> = { - tpm: async (msg) => { - const text = msg.message; - const [, ...args] = text.split(" "); - if (args.length === 0) { - await sendOrEditMessage(msg, this.description, { parseMode: "html" }); - return; - } - const cmd = args[0]; - if (cmd === "install" || cmd === "i") { - await installPlugin(args, msg); - } else if ( - cmd === "uninstall" || - cmd == "un" || - cmd === "remove" || - cmd === "rm" - ) { - const pluginNames = args.slice(1); - if (pluginNames.length === 0) { - await msg.edit({ text: "请提供要卸载的插件名称" }); - } else if (pluginNames.length === 1) { - const name = pluginNames[0].toLowerCase(); - if (name === "all") { - await uninstallAllPlugins(msg); - } else { - await uninstallPlugin(pluginNames[0], msg); - } - } else { - await uninstallMultiplePlugins(pluginNames, msg); - } - } else if (cmd == "upload" || cmd == "ul") { - await uploadPlugin(args, msg); - } else if (cmd === "search" || cmd === "s") { - await search(msg); - } else if (cmd === "list" || cmd === "ls" || cmd === "lv") { - await showPluginRecords( - msg, - ["-v", "--verbose"].includes(args[1]) || cmd === "lv" - ); - } else if (cmd === "update" || cmd === "updateAll" || cmd === "ua") { - await updateAllPlugins(msg); - } else { - await sendOrEditMessage(msg, `❌ 未知命令: ${codeTag(cmd)}\n\n${this.description}`, { parseMode: "html" }); - } - }, - }; -} - -export default new TpmPlugin(); - -if (require.main === module) { - const args = process.argv.slice(2); - if (args.length === 0 || args?.[0] !== "install" || args?.length < 2) { - console.log("Usage: node tpm.ts install plugin1 plugin2 ..."); - } - installPlugin(args, { - edit: async ({ text }: any) => { - console.log(text); - }, - } as any) - .then(() => { - console.log("Plugins processed successfully"); - }) - .catch((error) => { - console.error("Error processing plugins:", error); - }); -} +import { Plugin, isValidPlugin } from "@utils/pluginBase"; +import { + createDirectoryInTemp, + createDirectoryInAssets, +} from "@utils/pathHelpers"; +import path from "path"; +import fs from "fs"; +import axios from "axios"; +import { Api } from "teleproto"; +import { safeGetReplyMessage } from "@utils/safeGetMessages"; +import { JSONFilePreset } from "lowdb/node"; +import { getPrefixes } from "@utils/pluginManager"; +import { tryGetCurrentGenerationContext } from "@utils/globalClient"; +import { reloadRuntime } from "@utils/runtimeManager"; + +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; +const MAX_MESSAGE_LENGTH = 4000; +const PLUGINS_INDEX_URL = + "https://raw.githubusercontent.com/TeleBoxDev/TeleBox_Plugins/main/plugins.json"; +const REQUEST_TIMEOUT_MS = 20000; +const MAX_RETRIES = 4; +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); +const DEFAULT_HEADERS = { + "User-Agent": "TeleBox-TPM/1.0", + Accept: "application/json, text/plain, */*", +}; + +interface PluginRecord { + url: string; + desc?: string; + _updatedAt: number; +} + +type Database = Record; +type RemotePluginInfo = { url: string; desc?: string }; +type RemotePluginsIndex = Record; + +const PLUGIN_PATH = path.join(process.cwd(), "plugins"); + +class EntityManager { + private count = 0; + private readonly LIMIT = 100; + private readonly IMPORTANT_TAGS = ['blockquote', 'a', 'b', 'i', 'u']; + + canAdd(tag: string): boolean { + if (this.IMPORTANT_TAGS.includes(tag)) { + return true; + } + return this.count < this.LIMIT; + } + + add(tag: string) { + this.count++; + } + + getCount(): number { + return this.count; + } + + hasReachedLimit(): boolean { + return this.count >= this.LIMIT; + } +} + +function htmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function codeTag(value: string): string { + return `${htmlEscape(value)}`; +} + +async function sendOrEditMessage( + msg: Api.Message, + text: string, + options?: { parseMode?: string; linkPreview?: boolean } +): Promise { + const messageOptions = { + text, + parseMode: options?.parseMode || undefined, + linkPreview: options?.linkPreview !== false, + }; + + try { + await msg.edit(messageOptions); + return msg; + } catch (error) { + console.log(`[TPM] 编辑消息失败,尝试发送新消息: ${error}`); + } + + const sendOptions: any = { + message: text, + parseMode: options?.parseMode || undefined, + linkPreview: options?.linkPreview !== false, + }; + + if (msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId) { + sendOptions.replyTo = msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId; + } + + const newMsg = await msg.client?.sendMessage(msg.peerId, sendOptions); + return newMsg || msg; +} + +function scheduleRuntimeReload(): void { + setTimeout(() => { + void (async () => { + await reloadRuntime(); + })().catch((error) => { + console.error("[TPM] 插件变更后的 Runtime 重载失败:", error); + }); + }, 0); +} + +async function updateProgressMessage( + msg: Api.Message, + text: string, + options?: { parseMode?: string; linkPreview?: boolean } +): Promise { + const messageOptions = { + text, + parseMode: options?.parseMode || undefined, + linkPreview: options?.linkPreview !== false, + }; + + try { + await msg.edit(messageOptions); + return true; + } catch (error) { + console.log(`[TPM] 编辑进度消息失败,静默继续: ${error}`); + return false; + } +} + +function splitLongText(text: string, maxLength: number = MAX_MESSAGE_LENGTH): string[] { + if (text.length <= maxLength) { + return [text]; + } + + const messages: string[] = []; + const lines = text.split('\n'); + let currentMessage = ''; + + for (const line of lines) { + if (line.length > maxLength) { + if (currentMessage) { + messages.push(currentMessage); + currentMessage = ''; + } + for (let i = 0; i < line.length; i += maxLength) { + messages.push(line.substring(i, i + maxLength)); + } + continue; + } + + if (currentMessage.length + line.length + 1 > maxLength) { + messages.push(currentMessage); + currentMessage = line; + } else { + currentMessage += (currentMessage ? '\n' : '') + line; + } + } + + if (currentMessage) { + messages.push(currentMessage); + } + + return messages; +} + +async function sendLongMessage( + msg: Api.Message, + text: string, + options?: { parseMode?: string; linkPreview?: boolean }, + isEdit: boolean = true +): Promise { + const messages = splitLongText(text); + + if (messages.length === 0) { + return; + } + + const messageOptions = { + parseMode: options?.parseMode || undefined, + linkPreview: options?.linkPreview !== false, + }; + + if (isEdit) { + try { + await msg.edit({ + text: messages[0], + ...messageOptions, + }); + } catch (error) { + await msg.client?.sendMessage(msg.peerId, { + message: messages[0], + ...messageOptions, + replyTo: msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId, + }); + } + } else { + await msg.client?.sendMessage(msg.peerId, { + message: messages[0], + ...messageOptions, + replyTo: msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId, + }); + } + + for (let i = 1; i < messages.length; i++) { + await msg.reply({ + message: `📋 续 (${i}/${messages.length - 1}):\n\n
${messages[i]}
`, + ...messageOptions, + }); + } +} + +async function getDatabase() { + const filePath = path.join(createDirectoryInAssets("tpm"), "plugins.json"); + const db = await JSONFilePreset(filePath, {}); + return db; +} + +async function getMediaFileName(msg: any): Promise { + const metadata = msg.media as any; + return metadata.document.attributes[0].fileName; +} + +function normalizeGithubUrl(input: string): string { + try { + const parsed = new URL(input); + if (parsed.hostname === "github.com") { + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length >= 5 && parts[2] === "blob") { + const [owner, repo, , branch, ...rest] = parts; + return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${rest.join("/")}`; + } + return input; + } + if (parsed.hostname === "raw.githubusercontent.com") { + parsed.search = ""; + return parsed.toString(); + } + return input; + } catch { + return input; + } +} + +function getRetryDelayMs(error: unknown, attempt: number): number { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status === 429) { + const retryAfter = error.response?.headers?.["retry-after"]; + if (typeof retryAfter === "string") { + const seconds = Number.parseInt(retryAfter, 10); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()); + } + } + } + } + const base = 600 * Math.pow(2, attempt); + const jitter = Math.floor(Math.random() * 250); + return base + jitter; +} + +async function lifecycleDelay(ms: number, label: string): Promise { + const lifecycle = tryGetCurrentGenerationContext(); + if (lifecycle) { + await lifecycle.delay(ms, { label }); + return; + } + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWithRetry( + url: string, + options?: Parameters[1] +) { + let lastError: unknown; + const normalizedUrl = normalizeGithubUrl(url); + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await axios.get(normalizedUrl, { + timeout: REQUEST_TIMEOUT_MS, + ...options, + headers: { + ...DEFAULT_HEADERS, + ...(options?.headers || {}), + }, + }); + } catch (error) { + lastError = error; + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + if (!status || !RETRYABLE_STATUS.has(status) || attempt === MAX_RETRIES) { + throw error; + } + const delay = getRetryDelayMs(error, attempt); + await lifecycleDelay(delay, "tpm:fetch-retry"); + } + } + throw lastError; +} + +async function installRemotePlugin(plugin: string, msg: Api.Message) { + const statusMsg = await sendOrEditMessage(msg, `正在安装插件 ${plugin}...`); + const res = await fetchWithRetry(PLUGINS_INDEX_URL); + if (res.status === 200) { + if (!res.data[plugin]) { + await sendOrEditMessage(statusMsg, `未找到插件 ${plugin} 的远程资源`); + return; + } + const pluginUrl = normalizeGithubUrl(res.data[plugin].url); + const response = await fetchWithRetry(pluginUrl, { + responseType: "text", + }); + if (response.status !== 200) { + await sendOrEditMessage(statusMsg, `无法下载插件 ${plugin}`); + return; + } + const filePath = path.join(PLUGIN_PATH, `${plugin}.ts`); + const oldBackupPath = path.join(PLUGIN_PATH, `${plugin}.ts.backup`); + + if (fs.existsSync(filePath)) { + const cacheDir = createDirectoryInTemp("plugin_backups"); + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + const backupPath = path.join(cacheDir, `${plugin}_${timestamp}.ts.bak`); + fs.copyFileSync(filePath, backupPath); + console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); + } + + if (fs.existsSync(oldBackupPath)) { + fs.unlinkSync(oldBackupPath); + console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); + } + + fs.writeFileSync(filePath, response.data); + + try { + const db = await getDatabase(); + db.data[plugin] = { ...res.data[plugin], _updatedAt: Date.now() }; + await db.write(); + console.log(`[TPM] 已记录插件信息到数据库: ${plugin}`); + } catch (error) { + console.error(`[TPM] 记录插件信息失败: ${error}`); + } + + await sendOrEditMessage(statusMsg, `插件 ${plugin} 已安装并加载成功`); + scheduleRuntimeReload(); + } else { + await sendOrEditMessage(statusMsg, `无法获取远程插件库`); + } +} + +async function installAllPlugins(msg: Api.Message) { + const statusMsg = await sendOrEditMessage(msg, "🔍 正在获取远程插件列表..."); + try { + const res = await fetchWithRetry(PLUGINS_INDEX_URL); + if (res.status !== 200) { + await sendOrEditMessage(statusMsg, "❌ 无法获取远程插件库"); + return; + } + + const plugins = Object.keys(res.data); + const totalPlugins = plugins.length; + if (totalPlugins === 0) { + await sendOrEditMessage(statusMsg, "📦 远程插件库为空"); + return; + } + + let installedCount = 0; + let failedCount = 0; + const failedPlugins: string[] = []; + + await sendOrEditMessage(statusMsg, `📦 开始安装 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); + + for (let i = 0; i < plugins.length; i++) { + const plugin = plugins[i]; + const progress = Math.round(((i + 1) / totalPlugins) * 100); + const progressBar = htmlEscape(generateProgressBar(progress)); + try { + if ([0, plugins.length - 1].includes(i) || i % 2 === 0) { + await sendOrEditMessage(statusMsg, `📦 正在安装插件: ${codeTag(plugin)}\n\n${progressBar}\n🔄 进度: ${ + i + 1 + }/${totalPlugins} (${progress}%)\n✅ 成功: ${installedCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); + } + + const pluginData = res.data[plugin]; + if (!pluginData || !pluginData.url) { + failedCount++; + failedPlugins.push(`${plugin} (无URL)`); + continue; + } + + const pluginUrl = normalizeGithubUrl(pluginData.url); + const response = await fetchWithRetry(pluginUrl, { + responseType: "text", + }); + if (response.status !== 200) { + failedCount++; + failedPlugins.push(`${plugin} (下载失败)`); + continue; + } + + const filePath = path.join(PLUGIN_PATH, `${plugin}.ts`); + const oldBackupPath = path.join(PLUGIN_PATH, `${plugin}.ts.backup`); + + if (fs.existsSync(filePath)) { + const cacheDir = createDirectoryInTemp("plugin_backups"); + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + const backupPath = path.join(cacheDir, `${plugin}_${timestamp}.ts.bak`); + fs.copyFileSync(filePath, backupPath); + console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); + } + if (fs.existsSync(oldBackupPath)) { + fs.unlinkSync(oldBackupPath); + console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); + } + + fs.writeFileSync(filePath, response.data); + + try { + const db = await getDatabase(); + db.data[plugin] = { + url: pluginUrl, + desc: pluginData.desc, + _updatedAt: Date.now(), + }; + await db.write(); + console.log(`[TPM] 已记录插件信息到数据库: ${plugin}`); + } catch (dbError) { + console.error(`[TPM] 记录插件信息失败: ${dbError}`); + } + + installedCount++; + await lifecycleDelay(100, "tpm:batch-install-throttle"); + } catch (error) { + failedCount++; + failedPlugins.push(`${plugin} (${htmlEscape(String(error))})`); + console.error(`[TPM] 安装插件 ${plugin} 失败:`, error); + } + } + + const successBar = generateProgressBar(100); + let resultMsg = `🎉 批量安装完成!\n\n${successBar}\n\n📊 安装统计:\n✅ 成功安装: ${installedCount}/${totalPlugins}\n❌ 安装失败: ${failedCount}/${totalPlugins}`; + if (failedPlugins.length > 0) { + const failedList = failedPlugins.slice(0, 5).map(htmlEscape).join("\n• "); + const moreFailures = + failedPlugins.length > 5 + ? `\n• ... 还有 ${failedPlugins.length - 5} 个失败` + : ""; + resultMsg += `\n\n❌ 失败列表:\n• ${failedList}${moreFailures}`; + } + resultMsg += `\n\n🔄 插件已重新加载,可以开始使用!`; + + await sendOrEditMessage(statusMsg, resultMsg, { parseMode: "html" }); + scheduleRuntimeReload(); + } catch (error) { + await sendOrEditMessage(statusMsg, `❌ 批量安装失败: ${error}`); + console.error("[TPM] 批量安装插件失败:", error); + } +} + +async function installMultiplePlugins(pluginNames: string[], msg: Api.Message) { + const totalPlugins = pluginNames.length; + if (totalPlugins === 0) { + await sendOrEditMessage(msg, "❌ 未提供要安装的插件名称"); + return; + } + + const statusMsg = await sendOrEditMessage(msg, `🔍 正在获取远程插件列表...`, { parseMode: "html" }); + + try { + const res = await fetchWithRetry(PLUGINS_INDEX_URL); + if (res.status !== 200) { + await sendOrEditMessage(statusMsg, "❌ 无法获取远程插件库"); + return; + } + + let installedCount = 0; + let failedCount = 0; + const failedPlugins: string[] = []; + const notFoundPlugins: string[] = []; + + await sendOrEditMessage(statusMsg, `📦 开始安装 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); + + for (let i = 0; i < pluginNames.length; i++) { + const pluginName = pluginNames[i]; + const progress = Math.round(((i + 1) / totalPlugins) * 100); + const progressBar = htmlEscape(generateProgressBar(progress)); + + try { + if ([0, pluginNames.length - 1].includes(i) || i % 2 === 0) { + await sendOrEditMessage(statusMsg, `📦 正在安装插件: ${codeTag(pluginName)}\n\n${progressBar}\n🔄 进度: ${ + i + 1 + }/${totalPlugins} (${progress}%)\n✅ 成功: ${installedCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); + } + + if (!res.data[pluginName]) { + failedCount++; + notFoundPlugins.push(pluginName); + continue; + } + + const pluginData = res.data[pluginName]; + if (!pluginData.url) { + failedCount++; + failedPlugins.push(`${pluginName} (无URL)`); + continue; + } + + const pluginUrl = normalizeGithubUrl(pluginData.url); + const response = await fetchWithRetry(pluginUrl, { + responseType: "text", + }); + if (response.status !== 200) { + failedCount++; + failedPlugins.push(`${pluginName} (下载失败)`); + continue; + } + + const filePath = path.join(PLUGIN_PATH, `${pluginName}.ts`); + const oldBackupPath = path.join(PLUGIN_PATH, `${pluginName}.ts.backup`); + + if (fs.existsSync(filePath)) { + const cacheDir = createDirectoryInTemp("plugin_backups"); + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + const backupPath = path.join( + cacheDir, + `${pluginName}_${timestamp}.ts` + ); + fs.copyFileSync(filePath, backupPath); + console.log(`[TPM] 旧插件已转移到缓存: ${backupPath}`); + } + + if (fs.existsSync(oldBackupPath)) { + fs.unlinkSync(oldBackupPath); + console.log(`[TPM] 已清理旧备份文件: ${oldBackupPath}`); + } + + fs.writeFileSync(filePath, response.data); + + try { + const db = await getDatabase(); + db.data[pluginName] = { + url: pluginUrl, + desc: pluginData.desc, + _updatedAt: Date.now(), + }; + await db.write(); + console.log(`[TPM] 已记录插件信息到数据库: ${pluginName}`); + } catch (dbError) { + console.error(`[TPM] 记录插件信息失败: ${dbError}`); + } + + installedCount++; + await lifecycleDelay(100, "tpm:batch-install-throttle"); + } catch (error) { + failedCount++; + failedPlugins.push(`${pluginName} (${htmlEscape(String(error))})`); + console.error(`[TPM] 安装插件 ${pluginName} 失败:`, error); + } + } + + const successBar = generateProgressBar(100); + let resultMsg = `🎉 批量安装完成!\n\n${successBar}\n\n📊 安装统计:\n✅ 成功安装: ${installedCount}/${totalPlugins}\n❌ 安装失败: ${failedCount}/${totalPlugins}`; + + if (notFoundPlugins.length > 0) { + const notFoundList = notFoundPlugins.slice(0, 5).map(htmlEscape).join("\n• "); + const moreNotFound = + notFoundPlugins.length > 5 + ? `\n• ... 还有 ${notFoundPlugins.length - 5} 个未找到` + : ""; + resultMsg += `\n\n🔍 未找到的插件:\n• ${notFoundList}${moreNotFound}`; + } + + if (failedPlugins.length > 0) { + const failedList = failedPlugins.slice(0, 5).map(htmlEscape).join("\n• "); + const moreFailures = + failedPlugins.length > 5 + ? `\n• ... 还有 ${failedPlugins.length - 5} 个失败` + : ""; + resultMsg += `\n\n❌ 其他失败:\n• ${failedList}${moreFailures}`; + } + + resultMsg += `\n\n🔄 插件已重新加载,可以开始使用!`; + + await sendOrEditMessage(statusMsg, resultMsg, { parseMode: "html" }); + scheduleRuntimeReload(); + } catch (error) { + await sendOrEditMessage(statusMsg, `❌ 批量安装失败: ${error}`); + console.error("[TPM] 批量安装插件失败:", error); + } +} + +function generateProgressBar(percentage: number, length: number = 20): string { + const filled = Math.round((percentage / 100) * length); + const empty = length - filled; + const bar = "█".repeat(filled) + "░".repeat(empty); + return `🔄 当前进度: [${bar}] ${percentage}%`; +} + +async function installPlugin(args: string[], msg: Api.Message) { + if (args.length === 1) { + if (msg.isReply) { + const replied = await safeGetReplyMessage(msg); + if (replied?.media) { + const fileName = await getMediaFileName(replied); + + if (!fileName.endsWith(".ts")) { + await sendOrEditMessage(msg, `❌ 文件格式错误\n文件不是有效插件`); + return; + } + + const pluginName = fileName.replace(".ts", ""); + const statusMsg = await sendOrEditMessage(msg, `🔍 正在验证插件 ${pluginName} ...`); + const filePath = path.join(PLUGIN_PATH, fileName); + + await msg.client?.downloadMedia(replied, { outputFile: filePath }); + + try { + const pluginModule = require(filePath); + const pluginInstance = pluginModule.default || pluginModule; + + if (!isValidPlugin(pluginInstance)) { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + await sendOrEditMessage(statusMsg, `❌ 插件验证失败\n文件不是有效插件`); + return; + } + } catch (error) { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + await sendOrEditMessage(statusMsg, `❌ 插件加载失败\n错误信息:\n${error instanceof Error ? error.message : String(error)}`); + return; + } + + await sendOrEditMessage(statusMsg, `✅ 验证通过,正在安装插件 ${pluginName} ...`); + + let overrideMessage = ""; + try { + const db = await getDatabase(); + if (db.data[pluginName]) { + delete db.data[pluginName]; + await db.write(); + overrideMessage = `\n⚠️ 已覆盖之前已安装的远程插件\n若需保持更新, 请 ${codeTag(`${mainPrefix}tpm i ${pluginName}`)}`; + console.log(`[TPM] 已从数据库中清除同名插件记录: ${pluginName}`); + } + } catch (error) { + console.error(`[TPM] 清除数据库记录失败: ${error}`); + } + + await sendOrEditMessage(statusMsg, `✅ 插件 ${htmlEscape(pluginName)} 已安装并加载成功${overrideMessage}`, { parseMode: "html" }); + scheduleRuntimeReload(); + } else { + await sendOrEditMessage(msg, "请回复一个插件文件"); + } + } else { + await sendOrEditMessage(msg, "请回复某个插件文件或提供 tpm 包名"); + } + } else { + const pluginNames = args.slice(1); + + if (pluginNames.length === 1 && pluginNames[0] === "all") { + await installAllPlugins(msg); + } else if (pluginNames.length === 1) { + await installRemotePlugin(pluginNames[0], msg); + } else { + await installMultiplePlugins(pluginNames, msg); + } + } +} + +async function uninstallPlugin(plugin: string, msg: Api.Message) { + if (!plugin) { + await sendOrEditMessage(msg, "请提供要卸载的插件名称"); + return; + } + const statusMsg = await sendOrEditMessage(msg, `正在卸载插件 ${plugin}...`); + const pluginPath = path.join(PLUGIN_PATH, `${plugin}.ts`); + if (fs.existsSync(pluginPath)) { + fs.unlinkSync(pluginPath); + try { + const db = await getDatabase(); + if (db.data[plugin]) { + delete db.data[plugin]; + await db.write(); + console.log(`[TPM] 已从数据库中删除插件记录: ${plugin}`); + } + } catch (error) { + console.error(`[TPM] 删除插件数据库记录失败: ${error}`); + } + await sendOrEditMessage(statusMsg, `插件 ${plugin} 已卸载`); + scheduleRuntimeReload(); + } else { + await sendOrEditMessage(statusMsg, `未找到插件 ${plugin}`); + } +} + +async function uninstallMultiplePlugins( + pluginNames: string[], + msg: Api.Message +) { + if (!pluginNames || pluginNames.length === 0) { + await sendOrEditMessage(msg, "请提供要卸载的插件名称"); + return; + } + + const results: { name: string; success: boolean; reason?: string }[] = []; + let processedCount = 0; + const totalCount = pluginNames.length; + + const statusMsg = await sendOrEditMessage(msg, `开始卸载 ${totalCount} 个插件...\n${generateProgressBar( + 0 + )} 0/${totalCount}`); + + try { + const db = await getDatabase(); + + for (const pluginName of pluginNames) { + const trimmedName = pluginName.trim(); + if (!trimmedName) { + results.push({ + name: pluginName, + success: false, + reason: "插件名称为空", + }); + processedCount++; + continue; + } + + const pluginPath = path.join(PLUGIN_PATH, `${trimmedName}.ts`); + + if (fs.existsSync(pluginPath)) { + try { + fs.unlinkSync(pluginPath); + if (db.data[trimmedName]) { + delete db.data[trimmedName]; + console.log(`[TPM] 已从数据库中删除插件记录: ${trimmedName}`); + } + results.push({ name: trimmedName, success: true }); + } catch (error) { + console.error(`[TPM] 卸载插件 ${trimmedName} 失败:`, error); + results.push({ + name: trimmedName, + success: false, + reason: `删除失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } else { + results.push({ + name: trimmedName, + success: false, + reason: "插件不存在", + }); + } + + processedCount++; + const percentage = Math.round((processedCount / totalCount) * 100); + + await sendOrEditMessage(statusMsg, `卸载插件中...\n${generateProgressBar( + percentage + )} ${processedCount}/${totalCount}\n当前: ${trimmedName}`); + } + + await db.write(); + } catch (error) { + console.error(`[TPM] 批量卸载过程中发生错误:`, error); + await sendOrEditMessage(msg, `批量卸载过程中发生错误: ${ + error instanceof Error ? error.message : String(error) + }`); + return; + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + let resultText = `\n📊 卸载完成\n\n`; + resultText += `✅ 成功: ${successCount}\n`; + resultText += `❌ 失败: ${failedCount}\n\n`; + + if (successCount > 0) { + const successPlugins = results.filter((r) => r.success).map((r) => r.name); + resultText += `✅ 已卸载:\n${successPlugins + .map((name) => ` • ${name}`) + .join("\n")}\n\n`; + } + + if (failedCount > 0) { + const failedPlugins = results.filter((r) => !r.success); + resultText += `❌ 卸载失败:\n${failedPlugins + .map((r) => ` • ${r.name}: ${r.reason}`) + .join("\n")}`; + } + + await sendOrEditMessage(statusMsg, resultText); + scheduleRuntimeReload(); +} + +async function uninstallAllPlugins(msg: Api.Message) { + try { + const statusMsg = await sendOrEditMessage(msg, "⚠️ 正在清空插件目录并刷新缓存..."); + + let removed = 0; + let failed: string[] = []; + + try { + if (fs.existsSync(PLUGIN_PATH)) { + const files = fs.readdirSync(PLUGIN_PATH); + for (const file of files) { + const full = path.join(PLUGIN_PATH, file); + const isPluginTs = + file.endsWith(".ts") && + !file.includes("backup") && + !file.endsWith(".d.ts") && + !file.startsWith("_"); + if (!isPluginTs) continue; + try { + fs.unlinkSync(full); + removed++; + } catch (e) { + failed.push(file); + } + } + } + } catch (e) { + console.error("[TPM] 扫描插件目录失败:", e); + } + + try { + const db = await getDatabase(); + for (const k of Object.keys(db.data)) delete db.data[k]; + await db.write(); + } catch (e) { + console.error("[TPM] 清空数据库失败:", e); + } + + let text = `✅ 已清空插件目录并刷新缓存\n\n🗑 删除文件: ${removed}`; + if (failed.length) { + const show = failed.slice(0, 10).map(htmlEscape).join("\n• "); + text += `\n❌ 删除失败: ${failed.length}\n• ${show}${ + failed.length > 10 ? `\n• ... 还有 ${failed.length - 10} 个失败` : "" + }`; + } + await sendOrEditMessage(statusMsg, text, { parseMode: "html" }); + scheduleRuntimeReload(); + } catch (error) { + console.error("[TPM] 清空插件目录失败:", error); + await sendOrEditMessage(msg, `❌ 清空插件目录失败: ${error}`); + } +} + +async function uploadPlugin(args: string[], msg: Api.Message) { + const pluginName = args[1]; + if (!pluginName) { + await sendOrEditMessage(msg, "请提供插件名称"); + return; + } + const pluginPath = path.join(PLUGIN_PATH, `${pluginName}.ts`); + if (!fs.existsSync(pluginPath)) { + await sendOrEditMessage(msg, `未找到插件 ${pluginName}`); + return; + } + + const statusMsg = await sendOrEditMessage(msg, `正在上传插件 ${pluginName}...`); + + const sendOptions: any = { + file: pluginPath, + thumb: path.join(process.cwd(), "telebox.png"), + caption: `**TeleBox_Plugin ${pluginName} plugin.**`, + }; + + if (msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId) { + sendOptions.replyTo = msg.replyTo?.replyToTopId || msg.replyTo?.replyToMsgId; + } + + await msg.client?.sendFile(msg.peerId, sendOptions); + + if (statusMsg.id !== msg.id) { + await statusMsg.delete(); + } else { + await msg.delete(); + } +} + +async function search(msg: Api.Message) { + const text = msg.message; + const parts = text.trim().split(/\s+/); + const keyword = parts.length > 2 ? parts[2].toLowerCase() : ""; + + try { + const statusMsg = await sendOrEditMessage(msg, keyword ? `🔍 正在搜索插件: ${keyword}` : "🔍 正在获取插件列表..."); + const res = await fetchWithRetry(PLUGINS_INDEX_URL, { + headers: { + "Cache-Control": "no-cache", + Pragma: "no-cache", + }, + }); + if (res.status !== 200) { + await sendOrEditMessage(statusMsg, `❌ 无法获取远程插件库`); + return; + } + const remotePlugins = res.data; + const pluginNames = Object.keys(remotePlugins); + + const localPlugins = new Set(); + try { + if (fs.existsSync(PLUGIN_PATH)) { + const files = fs.readdirSync(PLUGIN_PATH); + files.forEach((file) => { + if (file.endsWith(".ts") && !file.includes("backup")) { + localPlugins.add(file.replace(".ts", "")); + } + }); + } + } catch (error) { + console.error("[TPM] 读取本地插件失败:", error); + } + + const db = await getDatabase(); + const dbPlugins = db.data; + + const filteredPlugins = keyword + ? pluginNames.filter(name => { + const pluginData = remotePlugins[name]; + const nameMatch = name.toLowerCase().includes(keyword); + const descMatch = pluginData?.desc?.toLowerCase().includes(keyword) || false; + return nameMatch || descMatch; + }) + : pluginNames; + + const totalPlugins = filteredPlugins.length; + + if (totalPlugins === 0 && keyword) { + await sendOrEditMessage(statusMsg, `🔍 未找到包含 "${htmlEscape(keyword)}" 的插件`, { parseMode: "html" }); + return; + } + + let installedCount = 0; + let localOnlyCount = 0; + let notInstalledCount = 0; + + const entityMgr = new EntityManager(); + + // 预留重要标签的位置 + entityMgr.add('b'); // 标题 + entityMgr.add('b'); // 统计标题 + entityMgr.add('b'); // 搜索关键词 + entityMgr.add('b'); // 搜索结果标题 + entityMgr.add('blockquote'); // 插件列表 + entityMgr.add('b'); // 快捷操作标题 + entityMgr.add('code'); // 第一个命令 + entityMgr.add('code'); // 第二个命令 + entityMgr.add('code'); // 第三个命令 + entityMgr.add('code'); // 第四个命令 + entityMgr.add('code'); // 第五个命令 + entityMgr.add('code'); // 第六个命令 + entityMgr.add('b'); // 仓库标题 + + const highlightMatch = (text: string) => { + const escapedText = htmlEscape(text); + if (!keyword) return escapedText; + const escapedKeyword = htmlEscape(keyword); + const regex = new RegExp(`(${escapedKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + return escapedText.replace(regex, '$1'); + }; + + function getPluginStatus(pluginName: string) { + const hasLocal = localPlugins.has(pluginName); + const dbRecord = dbPlugins[pluginName]; + + if (hasLocal && dbRecord) { + installedCount++; + return { status: "✅", label: "已安装" } as const; + } else if (hasLocal && !dbRecord) { + localOnlyCount++; + return { status: "🔶", label: "本地同名" } as const; + } else { + notInstalledCount++; + return { status: "❌", label: "未安装" } as const; + } + } + + const pluginLines: string[] = []; + for (const plugin of filteredPlugins) { + const pluginData = remotePlugins[plugin]; + const { status } = getPluginStatus(plugin); + const description = pluginData?.desc || "暂无描述"; + + const highlightedName = highlightMatch(plugin); + const highlightedDesc = highlightMatch(description); + + const allowCodeTag = entityMgr.canAdd('code'); + const nameTag = allowCodeTag && !keyword ? codeTag(plugin) : highlightedName; + + pluginLines.push(`${status} ${nameTag} - ${highlightedDesc}`); + + if (allowCodeTag) { + entityMgr.add('code'); + } + + if (keyword) { + entityMgr.add('b'); + } + } + + let statsInfo = `📊 插件统计:\n`; + if (keyword) { + statsInfo += `• 搜索关键词: "${htmlEscape(keyword)}"\n`; + } + statsInfo += `• 总计: ${totalPlugins} 个插件\n`; + statsInfo += `• ✅ 已安装: ${installedCount} 个\n`; + statsInfo += `• 🔶 本地同名: ${localOnlyCount} 个\n`; + statsInfo += `• ❌ 未安装: ${notInstalledCount} 个`; + + const installTip = `\n💡 快捷操作:\n` + + `• ${mainPrefix}tpm i [名称1] [名称2 ...] 安装/批量安装\n` + + `• ${mainPrefix}tpm i all 全部安装\n` + + `• ${mainPrefix}tpm update 更新已装\n` + + `• ${mainPrefix}tpm ls 查看记录\n` + + `• ${mainPrefix}tpm rm [名称] 卸载\n` + + `• ${mainPrefix}tpm rm all 清空`; + + const repoLink = `\n🔗 插件仓库: TeleBox_Plugins`; + + const title = keyword ? `🔍 搜索 "${htmlEscape(keyword)}" 结果` : `🔍 远程插件列表`; + const fullMessage = [ + `${title}`, + `━━━━━━━━━━━━━━━━━`, + "", + statsInfo, + "", + keyword ? `📦 搜索结果:` : `📦 插件详情:`, + `
${pluginLines.join("\n")}
`, + installTip, + repoLink + ].join("\n"); + + await sendLongMessage(statusMsg, fullMessage, { parseMode: "html", linkPreview: false }, true); + } catch (error) { + console.error("[TPM] 搜索插件失败:", error); + await sendOrEditMessage(msg, `❌ 搜索插件失败: ${error}`); + } +} + +async function showPluginRecords(msg: Api.Message, verbose?: boolean) { + try { + const statusMsg = await sendOrEditMessage(msg, "📚 正在读取插件数据..."); + const db = await getDatabase(); + const dbNames = Object.keys(db.data); + + let filePlugins: string[] = []; + try { + if (fs.existsSync(PLUGIN_PATH)) { + filePlugins = fs + .readdirSync(PLUGIN_PATH) + .filter( + (f) => + f.endsWith(".ts") && + !f.includes("backup") && + !f.endsWith(".d.ts") && + !f.startsWith("_") + ) + .map((f) => f.replace(/\.ts$/, "")); + } + } catch (err) { + console.error("[TPM] 读取本地插件目录失败:", err); + } + + const notInDb = filePlugins.filter((n) => !dbNames.includes(n)); + + const sortedPlugins = dbNames + .map((name) => ({ name, ...db.data[name] })) + .sort((a, b) => a._updatedAt - b._updatedAt); + + const entityMgr = new EntityManager(); + + entityMgr.add('b'); + entityMgr.add('b'); + entityMgr.add('b'); + entityMgr.add('b'); + entityMgr.add('b'); + entityMgr.add('blockquote'); + entityMgr.add('blockquote'); + + const dbLinesSimple: string[] = []; + const dbLinesVerbose: string[] = []; + + for (const p of sortedPlugins) { + const allowCodeTag = entityMgr.canAdd('code'); + + if (verbose) { + const updateTime = new Date(p._updatedAt).toLocaleString("zh-CN"); + const desc = p.desc ? `\n📝 ${htmlEscape(p.desc)}` : ""; + const nameTag = allowCodeTag ? codeTag(p.name) : htmlEscape(p.name); + const urlTag = allowCodeTag ? codeTag(p.url) : htmlEscape(p.url); + dbLinesVerbose.push(`${nameTag} 🕒 ${updateTime}${desc}\n🔗 ${urlTag}`); + + if (allowCodeTag) { + entityMgr.add('code'); + entityMgr.add('code'); + } + } else { + const nameTag = allowCodeTag ? codeTag(p.name) : htmlEscape(p.name); + dbLinesSimple.push(`${nameTag}${p.desc ? ` - ${htmlEscape(p.desc)}` : ""}`); + + if (allowCodeTag) { + entityMgr.add('code'); + } + } + } + + const localLinesSimple: string[] = []; + const localLinesVerbose: string[] = []; + + for (const name of notInDb) { + const allowCodeTag = entityMgr.canAdd('code'); + const nameTag = allowCodeTag ? codeTag(name) : htmlEscape(name); + + if (verbose) { + const filePath = path.join(PLUGIN_PATH, `${name}.ts`); + let mtime = "未知"; + try { + const stat = fs.statSync(filePath); + mtime = stat.mtime.toLocaleString("zh-CN"); + } catch {} + localLinesVerbose.push(`${nameTag} 🗄 ${mtime}`); + } else { + localLinesSimple.push(nameTag); + } + + if (allowCodeTag) { + entityMgr.add('code'); + } + } + + const tip = verbose + ? "" + : `💡 可使用 ${mainPrefix}tpm ls -v 查看详情信息`; + + const dbLines = verbose ? dbLinesVerbose : dbLinesSimple; + const localLines = verbose ? localLinesVerbose : localLinesSimple; + + const messageParts: string[] = []; + + messageParts.push(`📚 插件记录`); + messageParts.push(`━━━━━━━━━━━━━━━━━`); + + if (tip) { + messageParts.push("", tip); + entityMgr.add('code'); + } + + if (dbNames.length > 0) { + messageParts.push("", `📦 远程插件记录 (${dbNames.length}个):`); + messageParts.push(`
${dbLines.join("\n")}
`); + } else { + messageParts.push("", `📦 远程插件记录: (空)`); + } + + if (notInDb.length > 0) { + messageParts.push("", `🗂 本地插件 (${notInDb.length}个):`); + messageParts.push(`
${localLines.join("\n")}
`); + } + + messageParts.push("", `━━━━━━━━━━━━━━━━━`); + messageParts.push(`📊 总计: ${dbNames.length + notInDb.length} 个插件`); + + const fullMessage = messageParts.join("\n"); + + await sendLongMessage(statusMsg, fullMessage, { parseMode: "html", linkPreview: false }, true); + } catch (error) { + console.error("[TPM] 读取插件数据库失败:", error); + await sendOrEditMessage(msg, `❌ 读取数据库失败: ${error}`); + } +} + +async function updateAllPlugins(msg: Api.Message) { + const statusMsg = await sendOrEditMessage(msg, "🔍 正在检查待更新的插件..."); + let canEdit = true; + + try { + const db = await getDatabase(); + const dbPlugins = Object.keys(db.data); + + if (dbPlugins.length === 0) { + await sendOrEditMessage(statusMsg, "📦 数据库中没有已安装的插件记录"); + return; + } + + const totalPlugins = dbPlugins.length; + let updatedCount = 0; + let failedCount = 0; + let skipCount = 0; + const failedPlugins: string[] = []; + + if (canEdit) { + canEdit = await updateProgressMessage(statusMsg, `📦 开始更新 ${totalPlugins} 个插件...\n\n🔄 进度: 0/${totalPlugins} (0%)`, { parseMode: "html" }); + } + + for (let i = 0; i < dbPlugins.length; i++) { + const pluginName = dbPlugins[i]; + const pluginRecord = db.data[pluginName]; + const progress = Math.round(((i + 1) / totalPlugins) * 100); + const progressBar = htmlEscape(generateProgressBar(progress)); + + try { + if (canEdit && ([0, dbPlugins.length - 1].includes(i) || i % 2 === 0)) { + canEdit = await updateProgressMessage(statusMsg, `📦 正在更新插件: ${codeTag(pluginName)}\n\n${progressBar}\n🔄 进度: ${ + i + 1 + }/${totalPlugins} (${progress}%)\n✅ 成功: ${updatedCount}\n⏭️ 跳过: ${skipCount}\n❌ 失败: ${failedCount}`, { parseMode: "html" }); + } + + if (!pluginRecord.url) { + skipCount++; + console.log(`[TPM] 跳过更新插件 ${pluginName}: 无URL记录`); + continue; + } + + const response = await fetchWithRetry( + normalizeGithubUrl(pluginRecord.url), + { responseType: "text" } + ); + if (response.status !== 200) { + failedCount++; + failedPlugins.push(`${pluginName} (下载失败)`); + continue; + } + + const filePath = path.join(PLUGIN_PATH, `${pluginName}.ts`); + + if (!fs.existsSync(filePath)) { + skipCount++; + console.log(`[TPM] 跳过更新插件 ${pluginName}: 本地文件不存在`); + continue; + } + + const currentContent = fs.readFileSync(filePath, "utf8"); + if (currentContent === response.data) { + skipCount++; + console.log(`[TPM] 跳过更新插件 ${pluginName}: 内容无变化`); + continue; + } + + const cacheDir = createDirectoryInTemp("plugin_backups"); + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + const backupPath = path.join(cacheDir, `${pluginName}_${timestamp}.ts`); + fs.copyFileSync(filePath, backupPath); + console.log(`[TPM] 旧版本已备份到: ${backupPath}`); + + fs.writeFileSync(filePath, response.data); + + try { + db.data[pluginName]._updatedAt = Date.now(); + await db.write(); + console.log(`[TPM] 已更新插件数据库记录: ${pluginName}`); + } catch (dbError) { + console.error(`[TPM] 更新插件数据库记录失败: ${dbError}`); + } + + updatedCount++; + await lifecycleDelay(100, "tpm:update-throttle"); + } catch (error) { + failedCount++; + failedPlugins.push(`${pluginName} (${htmlEscape(String(error))})`); + console.error(`[TPM] 更新插件 ${pluginName} 失败:`, error); + } + } + + scheduleRuntimeReload(); + + try { + await statusMsg.delete(); + console.log(`[TPM] 更新完成。统计: 成功${updatedCount}个, 跳过${skipCount}个, 失败${failedCount}个`); + } catch (error) { + console.log(`[TPM] 删除状态消息失败: ${error}`); + try { + await statusMsg.edit({ + text: `✅ 更新完成 (成功${updatedCount}个, 跳过${skipCount}个, 失败${failedCount}个)`, + parseMode: "html" + }); + } catch (editError) { + console.log(`[TPM] 最终编辑也失败: ${editError}`); + } + } + } catch (error) { + console.error("[TPM] 一键更新失败:", error); + try { + await statusMsg.delete(); + } catch (deleteError) { + try { + await statusMsg.edit({ text: `❌ 一键更新失败: ${htmlEscape(String(error))}`, parseMode: "html" }); + } catch (editError) { + console.log(`[TPM] 错误消息编辑失败: ${editError}`); + } + } + } +} + +class TpmPlugin extends Plugin { + + description: string = `📦 TeleBox 插件管理器 (TPM) + +🔍 查看插件: +• ${mainPrefix}tpm search (别名: s) - 显示远程插件列表 +• ${mainPrefix}tpm ls (别名: list) - 查看已安装记录 +• ${mainPrefix}tpm ls -v${mainPrefix}tpm lv - 查看详细记录 + +⬇️ 安装插件: +• ${mainPrefix}tpm i [插件名] (别名: install) - 安装单个插件 +• ${mainPrefix}tpm i [插件名1] [插件名2] - 安装多个插件 +• ${mainPrefix}tpm i all - 一键安装全部远程插件 +• ${mainPrefix}tpm i (回复插件文件) - 安装本地插件文件 + +🔄 更新插件: +• ${mainPrefix}tpm update (别名: updateAll, ua) - 一键更新所有已安装的远程插件 + +🗑️ 卸载插件: +• ${mainPrefix}tpm rm [插件名] (别名: remove, uninstall, un) - 卸载单个插件 +• ${mainPrefix}tpm rm [插件名1] [插件名2] - 卸载多个插件 +• ${mainPrefix}tpm rm all - 清空插件目录并刷新本地缓存 + +⬆️ 上传插件: +• ${mainPrefix}tpm upload [插件名] (别名: ul) - 上传指定插件文件`; + + ignoreEdited: boolean = true; + + cmdHandlers: Record Promise> = { + tpm: async (msg) => { + const text = msg.message; + const [, ...args] = text.split(" "); + if (args.length === 0) { + await sendOrEditMessage(msg, this.description, { parseMode: "html" }); + return; + } + const cmd = args[0]; + if (cmd === "install" || cmd === "i") { + await installPlugin(args, msg); + } else if ( + cmd === "uninstall" || + cmd == "un" || + cmd === "remove" || + cmd === "rm" + ) { + const pluginNames = args.slice(1); + if (pluginNames.length === 0) { + await msg.edit({ text: "请提供要卸载的插件名称" }); + } else if (pluginNames.length === 1) { + const name = pluginNames[0].toLowerCase(); + if (name === "all") { + await uninstallAllPlugins(msg); + } else { + await uninstallPlugin(pluginNames[0], msg); + } + } else { + await uninstallMultiplePlugins(pluginNames, msg); + } + } else if (cmd == "upload" || cmd == "ul") { + await uploadPlugin(args, msg); + } else if (cmd === "search" || cmd === "s") { + await search(msg); + } else if (cmd === "list" || cmd === "ls" || cmd === "lv") { + await showPluginRecords( + msg, + ["-v", "--verbose"].includes(args[1]) || cmd === "lv" + ); + } else if (cmd === "update" || cmd === "updateAll" || cmd === "ua") { + await updateAllPlugins(msg); + } else { + await sendOrEditMessage(msg, `❌ 未知命令: ${codeTag(cmd)}\n\n${this.description}`, { parseMode: "html" }); + } + }, + }; +} + +export default new TpmPlugin(); + +if (require.main === module) { + const args = process.argv.slice(2); + if (args.length === 0 || args?.[0] !== "install" || args?.length < 2) { + console.log("Usage: node tpm.ts install plugin1 plugin2 ..."); + } + installPlugin(args, { + edit: async ({ text }: any) => { + console.log(text); + }, + } as any) + .then(() => { + console.log("Plugins processed successfully"); + }) + .catch((error) => { + console.error("Error processing plugins:", error); + }); +} diff --git a/src/utils/pluginManager.ts b/src/utils/pluginManager.ts index 62ec253..d0ca718 100644 --- a/src/utils/pluginManager.ts +++ b/src/utils/pluginManager.ts @@ -1,566 +1,561 @@ -import path from "path"; -import fs from "fs"; -import { isValidPlugin, Plugin } from "@utils/pluginBase"; -import { getGlobalClient, getCurrentGeneration } from "@utils/globalClient"; -import { NewMessageEvent, NewMessage } from "teleproto/events"; -import { AliasDB } from "./aliasDB"; -import { Api } from "teleproto"; -import { cronManager } from "./cronManager"; -import { - EditedMessage, - EditedMessageEvent, -} from "teleproto/events/EditedMessage"; -import type { TeleBoxRuntime } from "./runtimeManager"; - -type ClientEventBuilder = NonNullable[1]>; - -type MessageWithText = Api.Message & { - text?: string; - savedPeerId?: unknown; -}; - -type MutableMessageWithText = MessageWithText & { - message: string; - text: string; -}; - -type PluginEntry = { - original?: string; - aliasFinal?: string; - plugin: Plugin; -}; - -const validPlugins: Plugin[] = []; -const plugins: Map = new Map(); -const loadedPluginFiles: Set = new Set(); -let pluginLoadDepth = 0; - -const USER_PLUGIN_PATH = path.join(process.cwd(), "plugins"); -const DEFAUTL_PLUGIN_PATH = path.join(process.cwd(), "src", "plugin"); -const PROJECT_ROOT = process.cwd(); -const CACHE_PURGE_EXCLUDE = new Set([ - path.resolve(PROJECT_ROOT, "src/utils/globalClient.ts"), - path.resolve(PROJECT_ROOT, "src/utils/globalClient.js"), - path.resolve(PROJECT_ROOT, "src/utils/pluginManager.ts"), - path.resolve(PROJECT_ROOT, "src/utils/pluginManager.js"), - path.resolve(PROJECT_ROOT, "src/utils/pluginBase.ts"), - path.resolve(PROJECT_ROOT, "src/utils/pluginBase.js"), - path.resolve(PROJECT_ROOT, "src/utils/cronManager.ts"), - path.resolve(PROJECT_ROOT, "src/utils/cronManager.js"), - path.resolve(PROJECT_ROOT, "src/utils/runtimeManager.ts"), - path.resolve(PROJECT_ROOT, "src/utils/runtimeManager.js"), -]); - -let prefixes = [".", "。", "$"]; -const envPrefixes = - process.env.TB_PREFIX?.split(/\s+/g).filter((p) => p.length > 0) || []; -if (envPrefixes.length > 0) { - prefixes = envPrefixes; -} else if (process.env.NODE_ENV === "development") { - prefixes = ["!", "!"]; -} -console.log( - `[PREFIXES] ${prefixes.join(" ")} (${envPrefixes.length > 0 ? "" : "可"}使用环境变量 TB_PREFIX 覆盖, 多个前缀用空格分隔)` -); - -function getPrefixes(): string[] { - return prefixes; -} - -function setPrefixes(newList: string[]): void { - prefixes = newList; -} - -function normalizePath(filePath: string): string { - return path.resolve(filePath); -} - -function isProjectFile(filePath: string): boolean { - const normalized = normalizePath(filePath); - return normalized.startsWith(PROJECT_ROOT + path.sep); -} - -function shouldPurgeCache(filePath: string): boolean { - if (!filePath) return false; - const normalized = normalizePath(filePath); - if (!isProjectFile(normalized)) return false; - if (CACHE_PURGE_EXCLUDE.has(normalized)) return false; - if (normalized.includes(`${path.sep}node_modules${path.sep}`)) return false; - if (!/\.(ts|js|cjs|mjs|cts|mts)$/.test(normalized)) return false; - return true; -} - -function collectModuleSubtree(moduleId: string, visited = new Set()): Set { - const resolved = require.resolve(moduleId); - const mod = require.cache[resolved]; - if (!mod) return visited; - if (visited.has(mod.id)) return visited; - visited.add(mod.id); - - for (const child of mod.children || []) { - if (child?.id && shouldPurgeCache(child.id)) { - collectModuleSubtree(child.id, visited); - } - } - - return visited; -} - -function purgeModuleCache(modulePaths: Iterable): void { - const idsToDelete = new Set(); - - for (const filePath of modulePaths) { - try { - const resolved = require.resolve(filePath); - if (!shouldPurgeCache(resolved)) continue; - idsToDelete.add(resolved); - const subtree = collectModuleSubtree(resolved); - for (const id of subtree) { - if (shouldPurgeCache(id)) { - idsToDelete.add(id); - } - } - } catch { - // ignore unresolved files during cleanup - } - } - - for (const id of idsToDelete) { - delete require.cache[id]; - } - - if (idsToDelete.size > 0) { - console.log(`[RELOAD] Purged ${idsToDelete.size} module cache entries.`); - } -} - -function dynamicRequireWithDeps(filePath: string) { - try { - const normalized = normalizePath(filePath); - loadedPluginFiles.add(normalized); - delete require.cache[require.resolve(normalized)]; - return require(normalized); - } catch (err) { - console.error(`Failed to require ${filePath}:`, err); - return null; - } -} - -async function setPlugins(basePath: string) { - const files = fs - .readdirSync(basePath) - .filter((file) => file.endsWith(".ts")); - - const aliasDB = new AliasDB(); - const aliasList = aliasDB.list(); - aliasDB.close(); - - for await (const file of files) { - const pluginPath = path.resolve(basePath, file); - const mod = dynamicRequireWithDeps(pluginPath); - if (!mod) continue; - const plugin = mod.default; - - if (isValidPlugin(plugin)) { - if (!plugin.name) { - plugin.name = path.basename(file, ".ts"); - } - - validPlugins.push(plugin); - const cmds = Object.keys(plugin.cmdHandlers); - - for (const cmd of cmds) { - plugins.set(cmd, { plugin }); - - const relatedAliases = aliasList.filter( - (rec) => rec.final === cmd || rec.final.startsWith(cmd + " ") - ); - - for (const rec of relatedAliases) { - plugins.set(rec.original, { - plugin, - original: cmd, - aliasFinal: rec.final, - }); - } - } - } - } -} - -function isPluginLoadInProgress(): boolean { - return pluginLoadDepth > 0; -} - -function getPluginEntry(command: string): PluginEntry | undefined { - return plugins.get(command); -} - -function listCommands(): string[] { - return Array.from(plugins.keys()).sort((a, b) => a.localeCompare(b)); -} - -function getCommandFromMessage( - msg: Api.Message | string, - diyPrefixes?: string[] -): string | null { - let pfs = getPrefixes(); - if (diyPrefixes && diyPrefixes.length > 0) { - pfs = diyPrefixes; - } - const text = typeof msg === "string" ? msg : msg.message; - - const matched = pfs.find((p) => text.startsWith(p)); - if (!matched) return null; - - const rest = text.slice(matched.length).trim(); - if (!rest) return null; - - const parts = rest.split(/\s+/).filter(Boolean); - if (parts.length === 0) return null; - - const aliasDB = new AliasDB(); - let aliasCandidate: string | null = null; - for (let i = parts.length; i >= 1; i--) { - const candidate = parts.slice(0, i).join(" "); - if (aliasDB.get(candidate)) { - aliasCandidate = candidate; - break; - } - } - aliasDB.close(); - - if (aliasCandidate) { - return aliasCandidate; - } - - const cmd = parts[0]; - if (/^[a-z0-9_]+$/i.test(cmd)) return cmd; - - return null; -} - -async function dealCommandPluginWithMessage(param: { - cmd: string; - isEdited?: boolean; - msg: Api.Message; - trigger?: Api.Message; -}) { - const { cmd, msg, isEdited, trigger } = param; - const pluginEntry = getPluginEntry(cmd); - - try { - if (!pluginEntry) return; - - if (isEdited && pluginEntry.plugin.ignoreEdited) { - return; - } - - const original = pluginEntry.original; - let targetCmd = original || cmd; - let targetMsg: Api.Message = msg; - - if (original && pluginEntry.aliasFinal && pluginEntry.aliasFinal !== original) { - const pfs = getPrefixes(); - const base = msg as MessageWithText; - const text: string = base.message || base.text || ""; - const matched = pfs.find((p) => text.startsWith(p)) || ""; - const rest = text.slice(matched.length).trim(); - const parts = rest.split(/\s+/).filter(Boolean); - - const aliasParts = cmd.split(/\s+/).filter(Boolean); - const finalParts = pluginEntry.aliasFinal.split(/\s+/).filter(Boolean); - - if ( - parts.length >= aliasParts.length && - aliasParts.every((w, idx) => parts[idx] === w) - ) { - const extraParts = parts.slice(aliasParts.length); - const newRest = [...finalParts, ...extraParts].join(" "); - const newText = matched + newRest; - - const newMsg = Object.create(Object.getPrototypeOf(base)) as MutableMessageWithText; - Object.assign(newMsg, base); - - Object.defineProperty(newMsg, "message", { - value: newText, - writable: true, - configurable: true, - }); - Object.defineProperty(newMsg, "text", { - value: newText, - writable: true, - configurable: true, - }); - - targetMsg = newMsg as Api.Message; - } - } - - const handler = pluginEntry.plugin.cmdHandlers[targetCmd]; - if (handler) { - await handler(targetMsg, trigger); - } - } catch (error) { - console.error("Command handler error:", error); - const errorMsg = `处理命令时出错:${error instanceof Error ? error.message : String(error)}`; - try { - await msg.edit({ text: errorMsg }); - } catch (editError) { - console.error("Failed to show command error message (client may be destroyed):", editError); - } - } -} - -async function dealCommandPlugin( - event: NewMessageEvent | EditedMessageEvent -): Promise { - const msg = event.message; - const savedMessage = (msg as MessageWithText).savedPeerId; - if (msg.out || savedMessage) { - const cmd = getCommandFromMessage(msg); - if (cmd) { - const isEdited = event instanceof EditedMessageEvent; - await dealCommandPluginWithMessage({ cmd, msg, isEdited }); - } - } -} - -async function dealNewMsgEvent(event: NewMessageEvent): Promise { - await dealCommandPlugin(event); -} - -async function dealEditedMsgEvent(event: EditedMessageEvent): Promise { - await dealCommandPlugin(event); -} - -const listenerHandleEdited = - process.env.TB_LISTENER_HANDLE_EDITED?.split(/\s+/g).filter( - (p) => p.length > 0 - ) || []; - -console.log( - `[LISTENER_HANDLE_EDITED] 不忽略监听编辑的消息的插件: ${ - listenerHandleEdited.length === 0 - ? "未设置" - : listenerHandleEdited.join(", ") - } (可使用环境变量 TB_LISTENER_HANDLE_EDITED 设置, 多个插件用空格分隔)` -); - -async function runPluginSetup(plugin: Plugin, runtime: TeleBoxRuntime): Promise { - if (typeof plugin.setup !== "function") return; - await runtime.context.runTask( - async () => { - await plugin.setup?.({ - generation: runtime.generation, - signal: runtime.signal, - lifecycle: runtime.context, - }); - }, - { label: `plugin-setup:${plugin.name || "unknown"}` } - ); -} - -function trackClientEventHandler( - runtime: TeleBoxRuntime, - handler: (event: TEvent) => void | Promise, - eventBuilder: ClientEventBuilder, - label: string -): void { - const { client } = runtime; - runtime.context.trackListener( - (trackedHandler) => client.addEventHandler(trackedHandler, eventBuilder), - (trackedHandler) => client.removeEventHandler(trackedHandler, eventBuilder), - (event) => { - if (runtime.generation !== getCurrentGeneration()) return; - return handler(event); - }, - { label } - ); -} - -function dealListenMessagePlugin(runtime: TeleBoxRuntime): void { - for (const plugin of validPlugins) { - const messageHandler = plugin.listenMessageHandler; - if (messageHandler) { - trackClientEventHandler( - runtime, - async (event) => { - try { - await messageHandler(event.message); - } catch (error) { - console.log("listenMessageHandler NewMessage error:", error); - } - }, - new NewMessage(), - `listener:${plugin.name || "unknown"}:new-message` - ); - - if ( - !plugin.listenMessageHandlerIgnoreEdited || - (plugin.name && listenerHandleEdited.includes(plugin.name)) - ) { - trackClientEventHandler( - runtime, - async (event) => { - try { - await messageHandler(event.message, { isEdited: true }); - } catch (error) { - console.log("listenMessageHandler EditedMessage error:", error); - } - }, - new EditedMessage({}), - `listener:${plugin.name || "unknown"}:edited-message` - ); - } - } - - const eventHandlers = plugin.eventHandlers; - if (Array.isArray(eventHandlers) && eventHandlers.length > 0) { - for (const { event, handler } of eventHandlers) { - trackClientEventHandler( - runtime, - async (ev: unknown) => { - try { - await handler(ev); - } catch (error) { - console.log("eventHandler error:", error); - } - }, - event || new NewMessage(), - `event:${plugin.name || "unknown"}` - ); - } - } - } -} - -function dealCronPlugin(runtime: TeleBoxRuntime): void { - for (const plugin of validPlugins) { - const cronTasks = plugin.cronTasks; - if (cronTasks) { - const keys = Object.keys(cronTasks); - for (const key of keys) { - const cronTask = cronTasks[key]; - cronManager.set(key, cronTask.cron, async () => { - if (runtime.signal.aborted || runtime.generation !== getCurrentGeneration()) return; - const client = await getGlobalClient(); - await cronTask.handler(client); - }, runtime.context); - } - } - } -} - +import path from "path"; +import fs from "fs"; +import { isValidPlugin, Plugin } from "@utils/pluginBase"; +import { getGlobalClient, getCurrentGeneration } from "@utils/globalClient"; +import { NewMessageEvent, NewMessage } from "teleproto/events"; +import { AliasDB } from "./aliasDB"; +import { Api } from "teleproto"; +import { cronManager } from "./cronManager"; +import { + EditedMessage, + EditedMessageEvent, +} from "teleproto/events/EditedMessage"; +import type { TeleBoxRuntime } from "./runtimeManager"; + +type ClientEventBuilder = NonNullable[1]>; + +type MessageWithText = Api.Message & { + text?: string; + savedPeerId?: unknown; +}; + +type MutableMessageWithText = MessageWithText & { + message: string; + text: string; +}; + +type PluginEntry = { + original?: string; + aliasFinal?: string; + plugin: Plugin; +}; + +const validPlugins: Plugin[] = []; +const plugins: Map = new Map(); +const loadedPluginFiles: Set = new Set(); +let pluginLoadDepth = 0; + +const USER_PLUGIN_PATH = path.join(process.cwd(), "plugins"); +const DEFAUTL_PLUGIN_PATH = path.join(process.cwd(), "src", "plugin"); +const PROJECT_ROOT = process.cwd(); +const CACHE_PURGE_EXCLUDE = new Set([ + path.resolve(PROJECT_ROOT, "src/utils/globalClient.ts"), + path.resolve(PROJECT_ROOT, "src/utils/globalClient.js"), + path.resolve(PROJECT_ROOT, "src/utils/pluginManager.ts"), + path.resolve(PROJECT_ROOT, "src/utils/pluginManager.js"), + path.resolve(PROJECT_ROOT, "src/utils/pluginBase.ts"), + path.resolve(PROJECT_ROOT, "src/utils/pluginBase.js"), + path.resolve(PROJECT_ROOT, "src/utils/cronManager.ts"), + path.resolve(PROJECT_ROOT, "src/utils/cronManager.js"), + path.resolve(PROJECT_ROOT, "src/utils/runtimeManager.ts"), + path.resolve(PROJECT_ROOT, "src/utils/runtimeManager.js"), +]); + +let prefixes = [".", "。", "$"]; +const envPrefixes = + process.env.TB_PREFIX?.split(/\s+/g).filter((p) => p.length > 0) || []; +if (envPrefixes.length > 0) { + prefixes = envPrefixes; +} else if (process.env.NODE_ENV === "development") { + prefixes = ["!", "!"]; +} +console.log( + `[PREFIXES] ${prefixes.join(" ")} (${envPrefixes.length > 0 ? "" : "可"}使用环境变量 TB_PREFIX 覆盖, 多个前缀用空格分隔)` +); + +function getPrefixes(): string[] { + return prefixes; +} + +function setPrefixes(newList: string[]): void { + prefixes = newList; +} + +function normalizePath(filePath: string): string { + return path.resolve(filePath); +} + +function isProjectFile(filePath: string): boolean { + const normalized = normalizePath(filePath); + return normalized.startsWith(PROJECT_ROOT + path.sep); +} + +function shouldPurgeCache(filePath: string): boolean { + if (!filePath) return false; + const normalized = normalizePath(filePath); + if (!isProjectFile(normalized)) return false; + if (CACHE_PURGE_EXCLUDE.has(normalized)) return false; + if (normalized.includes(`${path.sep}node_modules${path.sep}`)) return false; + if (!/\.(ts|js|cjs|mjs|cts|mts)$/.test(normalized)) return false; + return true; +} + +function collectModuleSubtree(moduleId: string, visited = new Set()): Set { + const resolved = require.resolve(moduleId); + const mod = require.cache[resolved]; + if (!mod) return visited; + if (visited.has(mod.id)) return visited; + visited.add(mod.id); + + for (const child of mod.children || []) { + if (child?.id && shouldPurgeCache(child.id)) { + collectModuleSubtree(child.id, visited); + } + } + + return visited; +} + +function purgeModuleCache(modulePaths: Iterable): void { + const idsToDelete = new Set(); + + for (const filePath of modulePaths) { + try { + const resolved = require.resolve(filePath); + if (!shouldPurgeCache(resolved)) continue; + idsToDelete.add(resolved); + const subtree = collectModuleSubtree(resolved); + for (const id of subtree) { + if (shouldPurgeCache(id)) { + idsToDelete.add(id); + } + } + } catch { + // ignore unresolved files during cleanup + } + } + + for (const id of idsToDelete) { + delete require.cache[id]; + } + + if (idsToDelete.size > 0) { + console.log(`[RELOAD] Purged ${idsToDelete.size} module cache entries.`); + } +} + +function dynamicRequireWithDeps(filePath: string) { + try { + const normalized = normalizePath(filePath); + loadedPluginFiles.add(normalized); + delete require.cache[require.resolve(normalized)]; + return require(normalized); + } catch (err) { + console.error(`Failed to require ${filePath}:`, err); + return null; + } +} + +async function setPlugins(basePath: string) { + const files = fs + .readdirSync(basePath) + .filter((file) => file.endsWith(".ts")); + + const aliasDB = new AliasDB(); + const aliasList = aliasDB.list(); + aliasDB.close(); + + for await (const file of files) { + const pluginPath = path.resolve(basePath, file); + const mod = dynamicRequireWithDeps(pluginPath); + if (!mod) continue; + const plugin = mod.default; + + if (isValidPlugin(plugin)) { + if (!plugin.name) { + plugin.name = path.basename(file, ".ts"); + } + + validPlugins.push(plugin); + const cmds = Object.keys(plugin.cmdHandlers); + + for (const cmd of cmds) { + plugins.set(cmd, { plugin }); + + const relatedAliases = aliasList.filter( + (rec) => rec.final === cmd || rec.final.startsWith(cmd + " ") + ); + + for (const rec of relatedAliases) { + plugins.set(rec.original, { + plugin, + original: cmd, + aliasFinal: rec.final, + }); + } + } + } + } +} + +function isPluginLoadInProgress(): boolean { + return pluginLoadDepth > 0; +} + +function getPluginEntry(command: string): PluginEntry | undefined { + return plugins.get(command); +} + +function listCommands(): string[] { + return Array.from(plugins.keys()).sort((a, b) => a.localeCompare(b)); +} + +function getCommandFromMessage( + msg: Api.Message | string, + diyPrefixes?: string[] +): string | null { + let pfs = getPrefixes(); + if (diyPrefixes && diyPrefixes.length > 0) { + pfs = diyPrefixes; + } + const text = typeof msg === "string" ? msg : msg.message; + + const matched = pfs.find((p) => text.startsWith(p)); + if (!matched) return null; + + const rest = text.slice(matched.length).trim(); + if (!rest) return null; + + const parts = rest.split(/\s+/).filter(Boolean); + if (parts.length === 0) return null; + + const aliasDB = new AliasDB(); + let aliasCandidate: string | null = null; + for (let i = parts.length; i >= 1; i--) { + const candidate = parts.slice(0, i).join(" "); + if (aliasDB.get(candidate)) { + aliasCandidate = candidate; + break; + } + } + aliasDB.close(); + + if (aliasCandidate) { + return aliasCandidate; + } + + const cmd = parts[0]; + if (/^[a-z0-9_]+$/i.test(cmd)) return cmd; + + return null; +} + +async function dealCommandPluginWithMessage(param: { + cmd: string; + isEdited?: boolean; + msg: Api.Message; + trigger?: Api.Message; +}) { + const { cmd, msg, isEdited, trigger } = param; + const pluginEntry = getPluginEntry(cmd); + + try { + if (!pluginEntry) return; + + if (isEdited && pluginEntry.plugin.ignoreEdited) { + return; + } + + const original = pluginEntry.original; + let targetCmd = original || cmd; + let targetMsg: Api.Message = msg; + + if (original && pluginEntry.aliasFinal && pluginEntry.aliasFinal !== original) { + const pfs = getPrefixes(); + const base = msg as MessageWithText; + const text: string = base.message || base.text || ""; + const matched = pfs.find((p) => text.startsWith(p)) || ""; + const rest = text.slice(matched.length).trim(); + const parts = rest.split(/\s+/).filter(Boolean); + + const aliasParts = cmd.split(/\s+/).filter(Boolean); + const finalParts = pluginEntry.aliasFinal.split(/\s+/).filter(Boolean); + + if ( + parts.length >= aliasParts.length && + aliasParts.every((w, idx) => parts[idx] === w) + ) { + const extraParts = parts.slice(aliasParts.length); + const newRest = [...finalParts, ...extraParts].join(" "); + const newText = matched + newRest; + + const newMsg = Object.create(Object.getPrototypeOf(base)) as MutableMessageWithText; + Object.assign(newMsg, base); + + Object.defineProperty(newMsg, "message", { + value: newText, + writable: true, + configurable: true, + }); + Object.defineProperty(newMsg, "text", { + value: newText, + writable: true, + configurable: true, + }); + + targetMsg = newMsg as Api.Message; + } + } + + const handler = pluginEntry.plugin.cmdHandlers[targetCmd]; + if (handler) { + await handler(targetMsg, trigger); + } + } catch (error) { + console.error("Command handler error:", error); + const errorMsg = `处理命令时出错:${error instanceof Error ? error.message : String(error)}`; + try { + await msg.edit({ text: errorMsg }); + } catch (editError) { + console.error("Failed to show command error message (client may be destroyed):", editError); + } + } +} + +async function dealCommandPlugin( + event: NewMessageEvent | EditedMessageEvent +): Promise { + const msg = event.message; + const savedMessage = (msg as MessageWithText).savedPeerId; + if (msg.out || savedMessage) { + const cmd = getCommandFromMessage(msg); + if (cmd) { + const isEdited = event instanceof EditedMessageEvent; + await dealCommandPluginWithMessage({ cmd, msg, isEdited }); + } + } +} + +async function dealNewMsgEvent(event: NewMessageEvent): Promise { + await dealCommandPlugin(event); +} + +async function dealEditedMsgEvent(event: EditedMessageEvent): Promise { + await dealCommandPlugin(event); +} + +const listenerHandleEdited = + process.env.TB_LISTENER_HANDLE_EDITED?.split(/\s+/g).filter( + (p) => p.length > 0 + ) || []; + +console.log( + `[LISTENER_HANDLE_EDITED] 不忽略监听编辑的消息的插件: ${ + listenerHandleEdited.length === 0 + ? "未设置" + : listenerHandleEdited.join(", ") + } (可使用环境变量 TB_LISTENER_HANDLE_EDITED 设置, 多个插件用空格分隔)` +); + +async function runPluginSetup(plugin: Plugin, runtime: TeleBoxRuntime): Promise { + if (typeof plugin.setup !== "function") return; + await runtime.context.runTask( + async () => { + await plugin.setup?.({ + generation: runtime.generation, + signal: runtime.signal, + lifecycle: runtime.context, + }); + }, + { label: `plugin-setup:${plugin.name || "unknown"}` } + ); +} + +function trackClientEventHandler( + runtime: TeleBoxRuntime, + handler: (event: TEvent) => void | Promise, + eventBuilder: ClientEventBuilder, + label: string +): void { + const { client } = runtime; + runtime.context.trackListener( + (trackedHandler) => client.addEventHandler(trackedHandler, eventBuilder), + (trackedHandler) => client.removeEventHandler(trackedHandler, eventBuilder), + (event) => { + if (runtime.generation !== getCurrentGeneration()) return; + return handler(event); + }, + { label } + ); +} + +function dealListenMessagePlugin(runtime: TeleBoxRuntime): void { + for (const plugin of validPlugins) { + const messageHandler = plugin.listenMessageHandler; + if (messageHandler) { + trackClientEventHandler( + runtime, + async (event) => { + try { + await messageHandler(event.message); + } catch (error) { + console.log("listenMessageHandler NewMessage error:", error); + } + }, + new NewMessage(), + `listener:${plugin.name || "unknown"}:new-message` + ); + + if ( + !plugin.listenMessageHandlerIgnoreEdited || + (plugin.name && listenerHandleEdited.includes(plugin.name)) + ) { + trackClientEventHandler( + runtime, + async (event) => { + try { + await messageHandler(event.message, { isEdited: true }); + } catch (error) { + console.log("listenMessageHandler EditedMessage error:", error); + } + }, + new EditedMessage({}), + `listener:${plugin.name || "unknown"}:edited-message` + ); + } + } + + const eventHandlers = plugin.eventHandlers; + if (Array.isArray(eventHandlers) && eventHandlers.length > 0) { + for (const { event, handler } of eventHandlers) { + trackClientEventHandler( + runtime, + async (ev: unknown) => { + try { + await handler(ev); + } catch (error) { + console.log("eventHandler error:", error); + } + }, + event || new NewMessage(), + `event:${plugin.name || "unknown"}` + ); + } + } + } +} + +function dealCronPlugin(runtime: TeleBoxRuntime): void { + for (const plugin of validPlugins) { + const cronTasks = plugin.cronTasks; + if (cronTasks) { + const keys = Object.keys(cronTasks); + for (const key of keys) { + const cronTask = cronTasks[key]; + cronManager.set(key, cronTask.cron, async () => { + if (runtime.signal.aborted || runtime.generation !== getCurrentGeneration()) return; + const client = await getGlobalClient(); + await cronTask.handler(client); + }, runtime.context); + } + } + } +} + async function runPluginCleanup(plugin: Plugin, runtime: TeleBoxRuntime): Promise { if (typeof plugin.cleanup !== "function") return; - await runtime.context.runTask( - async () => { - try { - await plugin.cleanup?.(); - } catch (error) { - console.error(`[RELOAD] Plugin cleanup failed: ${plugin.name || "unknown"}`, error); - } - }, - { label: `plugin-cleanup:${plugin.name || "unknown"}` } - ); -} - -async function unloadPluginsForRuntime(runtime: TeleBoxRuntime) { - const oldPlugins = [...validPlugins]; - const oldPluginFiles = [...loadedPluginFiles]; - - if (!runtime.signal.aborted) { - runtime.context.abort(`Unload generation ${runtime.generation}`); - } - - for (const plugin of oldPlugins) { - await runPluginCleanup(plugin, runtime); - } - - const snapshot = runtime.context.snapshot(); - const handlerCount = runtime.client.listEventHandlers().length; - const disposableCount = snapshot.trackedDisposables; - const resourceSummary = Object.entries(snapshot.stats) - .filter(([, stat]) => stat.created > 0 || stat.active > 0) - .map(([kind, stat]) => `${kind}:active=${stat.active},created=${stat.created}`) - .join("; ") || "none"; - const residualSummary = snapshot.residualResources - .slice(0, 10) - .map((resource) => `${resource.kind}:${resource.label}:${resource.state}:${resource.ageMs}ms`) - .join("; ") || "none"; - console.log( - `[RELOAD] Generation ${runtime.generation} stopped ingress; ${handlerCount} client handlers and ${disposableCount} lifecycle disposables are awaiting drain. resources=[${resourceSummary}] residual=[${residualSummary}]` - ); - - validPlugins.length = 0; - plugins.clear(); - loadedPluginFiles.clear(); - purgeModuleCache(oldPluginFiles); -} - -async function loadPluginsForRuntime(runtime: TeleBoxRuntime) { - pluginLoadDepth++; try { - await setPlugins(USER_PLUGIN_PATH); - await setPlugins(DEFAUTL_PLUGIN_PATH); - } finally { - pluginLoadDepth--; - } - - for (const plugin of validPlugins) { - await runPluginSetup(plugin, runtime); - } - - const { client } = runtime; - trackClientEventHandler( - runtime, - dealNewMsgEvent, - new NewMessage(), - "root:new-message" - ); - trackClientEventHandler( - runtime, - dealEditedMsgEvent, - new EditedMessage({}), - "root:edited-message" - ); - dealListenMessagePlugin(runtime); - dealCronPlugin(runtime); - console.log(`[RELOAD] Event handlers registered after reload: ${client.listEventHandlers().length}`); -} - -async function loadPlugins(): Promise { - const { tryGetCurrentRuntime }: typeof import("./runtimeManager") = require("./runtimeManager"); - const runtime = tryGetCurrentRuntime(); - - if (!runtime) { - console.warn( - "[RELOAD] Skip plugin reload because TeleBox runtime is not initialized. Call loadPlugins() from a command handler or another runtime-backed flow, not during plugin module initialization." - ); - return false; - } - - if (isPluginLoadInProgress()) { - console.warn( - "[RELOAD] Skip nested plugin reload while plugins are still being required. Move loadPlugins() out of module top-level initialization." - ); - return false; + await plugin.cleanup(); + } catch (error) { + console.error(`[RELOAD] Plugin cleanup failed: ${plugin.name || "unknown"}`, error); } - - await unloadPluginsForRuntime(runtime); - await loadPluginsForRuntime(runtime); - return true; } - -export { - getPrefixes, - setPrefixes, - loadPlugins, - loadPluginsForRuntime, - unloadPluginsForRuntime, - listCommands, - getPluginEntry, - dealCommandPluginWithMessage, - getCommandFromMessage, -}; + +async function unloadPluginsForRuntime(runtime: TeleBoxRuntime) { + const oldPlugins = [...validPlugins]; + const oldPluginFiles = [...loadedPluginFiles]; + + if (!runtime.signal.aborted) { + runtime.context.abort(`Unload generation ${runtime.generation}`); + } + + for (const plugin of oldPlugins) { + await runPluginCleanup(plugin, runtime); + } + + const snapshot = runtime.context.snapshot(); + const handlerCount = runtime.client.listEventHandlers().length; + const disposableCount = snapshot.trackedDisposables; + const resourceSummary = Object.entries(snapshot.stats) + .filter(([, stat]) => stat.created > 0 || stat.active > 0) + .map(([kind, stat]) => `${kind}:active=${stat.active},created=${stat.created}`) + .join("; ") || "none"; + const residualSummary = snapshot.residualResources + .slice(0, 10) + .map((resource) => `${resource.kind}:${resource.label}:${resource.state}:${resource.ageMs}ms`) + .join("; ") || "none"; + console.log( + `[RELOAD] Generation ${runtime.generation} stopped ingress; ${handlerCount} client handlers and ${disposableCount} lifecycle disposables are awaiting drain. resources=[${resourceSummary}] residual=[${residualSummary}]` + ); + + validPlugins.length = 0; + plugins.clear(); + loadedPluginFiles.clear(); + purgeModuleCache(oldPluginFiles); +} + +async function loadPluginsForRuntime(runtime: TeleBoxRuntime) { + pluginLoadDepth++; + try { + await setPlugins(USER_PLUGIN_PATH); + await setPlugins(DEFAUTL_PLUGIN_PATH); + } finally { + pluginLoadDepth--; + } + + for (const plugin of validPlugins) { + await runPluginSetup(plugin, runtime); + } + + const { client } = runtime; + trackClientEventHandler( + runtime, + dealNewMsgEvent, + new NewMessage(), + "root:new-message" + ); + trackClientEventHandler( + runtime, + dealEditedMsgEvent, + new EditedMessage({}), + "root:edited-message" + ); + dealListenMessagePlugin(runtime); + dealCronPlugin(runtime); + console.log(`[RELOAD] Event handlers registered after reload: ${client.listEventHandlers().length}`); +} + +async function loadPlugins(): Promise { + const { tryGetCurrentRuntime }: typeof import("./runtimeManager") = require("./runtimeManager"); + const runtime = tryGetCurrentRuntime(); + + if (!runtime) { + console.warn( + "[RELOAD] Skip plugin reload because TeleBox runtime is not initialized. Call loadPlugins() from a command handler or another runtime-backed flow, not during plugin module initialization." + ); + return false; + } + + if (isPluginLoadInProgress()) { + console.warn( + "[RELOAD] Skip nested plugin reload while plugins are still being required. Move loadPlugins() out of module top-level initialization." + ); + return false; + } + + await unloadPluginsForRuntime(runtime); + await loadPluginsForRuntime(runtime); + return true; +} + +export { + getPrefixes, + setPrefixes, + loadPlugins, + loadPluginsForRuntime, + unloadPluginsForRuntime, + listCommands, + getPluginEntry, + dealCommandPluginWithMessage, + getCommandFromMessage, +}; diff --git a/src/utils/runtimeManager.ts b/src/utils/runtimeManager.ts index 37960ca..05b5f3d 100644 --- a/src/utils/runtimeManager.ts +++ b/src/utils/runtimeManager.ts @@ -1,365 +1,364 @@ -import { TelegramClient } from "teleproto"; -import { StringSession } from "teleproto/sessions"; -import { getApiConfig } from "./apiConfig"; -import { readAppName } from "./teleboxInfoHelper"; -import { logger } from "./logger"; -import { initializeClientSession } from "./loginManager"; -import { - loadPluginsForRuntime, - unloadPluginsForRuntime, -} from "./pluginManager"; - -import { - createGenerationContext, - type DrainResult, - type GenerationContext, - type GenerationContextSnapshot, - type GenerationResourceStats, - type ResourceResidual, -} from "./generationContext"; - -export type RuntimeState = - | "starting" - | "running" - | "reloading" - | "stopping" - | "draining" - | "failed"; - -export interface TeleBoxRuntime { - generation: number; - state: RuntimeState; - client: TelegramClient; - context: GenerationContext; - signal: AbortSignal; - createdAt: number; - meId?: string; -} - -const RUNTIME_DRAIN_TIMEOUT_MS = 15_000; -const CLIENT_DESTROY_TIMEOUT_MS = 15_000; - -let currentRuntime: TeleBoxRuntime | null = null; -let transitionPromise: Promise | null = null; -let nextGeneration = 1; - -function formatResourceStats(stats: GenerationResourceStats): string { - return Object.entries(stats) - .filter(([, value]) => value.created > 0 || value.active > 0 || value.canceled > 0 || value.timedOut > 0) - .map(([kind, value]) => { - return `${kind}=active:${value.active},created:${value.created},drained:${value.completed},canceled:${value.canceled},timedOut:${value.timedOut}`; - }) - .join("; ") || "none"; -} - -function formatResidualResources(residuals: ResourceResidual[], limit = 12): string { - if (residuals.length === 0) return "none"; - const formatted = residuals.slice(0, limit).map((resource) => { - return `${resource.kind}#${resource.id}:${resource.label}:${resource.state}:${resource.ageMs}ms`; - }); - if (residuals.length > limit) { - formatted.push(`+${residuals.length - limit} more`); - } - return formatted.join("; "); -} - -function logGenerationSnapshot(prefix: string, snapshot: GenerationContextSnapshot): void { - console.log( - `${prefix} generation=${snapshot.generation} state=${snapshot.state} tasks=${snapshot.trackedTasks} disposables=${snapshot.trackedDisposables} stats=[${formatResourceStats(snapshot.stats)}] residual=[${formatResidualResources(snapshot.residualResources)}]` - ); -} - -function logDrainResult(runtime: TeleBoxRuntime, reason: string, result: DrainResult): void { - const residual = formatResidualResources(result.residualResources); - console.log( - `[RUNTIME] Generation ${runtime.generation} ${reason} diagnostics: canceled=${result.canceledResources}, drained=${result.drainedResources}, timedOut=${result.timedOutResources}, residual=${result.residualResources.length}, stats=[${formatResourceStats(result.stats)}], residualDetail=[${residual}]` - ); -} - -function cloneEmptyDrainStats(stats: GenerationResourceStats): GenerationResourceStats { - const cloned = {} as GenerationResourceStats; - for (const [kind, value] of Object.entries(stats)) { - cloned[kind as keyof GenerationResourceStats] = { ...value }; - } - return cloned; -} - -async function withTimeout( - promise: Promise, - ms: number, - label: string -): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new Error(`${label} timed out after ${ms}ms`)); - }, ms); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -async function createClient(): Promise { - const api = await getApiConfig(); - const proxy = api.proxy; - if (proxy) { - console.log("使用代理连接 Telegram:", proxy); - } - - let connectionRetries = 5; - const envValue = process.env.TB_CONNECTION_RETRIES; - if (envValue) { - const parsed = Number(envValue); - connectionRetries = Number.isInteger(parsed) ? parsed : 5; - } - - console.log( - `连接重试次数: ${connectionRetries}, 可使用环境变量 TB_CONNECTION_RETRIES 设置` - ); - - const client = new TelegramClient( - new StringSession(api.session), - api.api_id!, - api.api_hash!, - { connectionRetries, deviceModel: readAppName(), proxy } - ); - client.setLogLevel(logger.getGramJSLogLevel() as never); - return client; -} - -async function destroyClient(client: TelegramClient): Promise { - await withTimeout(client.destroy(), CLIENT_DESTROY_TIMEOUT_MS, "destroy client"); -} - -async function buildRuntime(): Promise { - const client = await createClient(); - const generation = nextGeneration++; - const context = createGenerationContext(generation); - const runtime: TeleBoxRuntime = { - generation, - state: "starting", - client, - context, - signal: context.signal, - createdAt: Date.now(), - }; - - const sessionInfo = await context.runTask( - async () => await initializeClientSession(client, context), - { label: "runtime:initialize-client-session" } - ); - runtime.meId = sessionInfo.meId; - return runtime; -} - -async function startFreshRuntime(): Promise { - const runtime = await buildRuntime(); - currentRuntime = runtime; - try { - await loadPluginsForRuntime(runtime); - runtime.state = "running"; - return runtime; - } catch (error) { - runtime.state = "failed"; - currentRuntime = null; - runtime.context.abort("Runtime startup failed"); - await runtime.context.dispose(RUNTIME_DRAIN_TIMEOUT_MS).catch((disposeError) => { - console.error("[RUNTIME] Failed to dispose runtime after startup error:", disposeError); - }); - await destroyClient(runtime.client).catch((destroyError) => { - console.error("[RUNTIME] Failed to destroy runtime after startup error:", destroyError); - }); - throw error; - } -} - -async function drainRuntime( - runtime: TeleBoxRuntime, - reason: string, - timeoutMs = RUNTIME_DRAIN_TIMEOUT_MS -): Promise { - runtime.state = "draining"; - console.log(`[RUNTIME] Generation ${runtime.generation} aborting: ${reason}`); - logGenerationSnapshot("[RUNTIME] Pre-drain snapshot", runtime.context.snapshot()); - runtime.context.abort(reason); - const result = await runtime.context.dispose(timeoutMs); - logDrainResult(runtime, reason, result); - if (result.timedOut) { - console.warn( - `[RUNTIME] Generation ${runtime.generation} drain timed out with ${result.pendingTasks} pending tasks and ${result.pendingDisposables} pending disposables.` - ); - } else if (result.errors.length > 0) { - console.warn( - `[RUNTIME] Generation ${runtime.generation} drained with ${result.errors.length} disposable error(s).` - ); - } else { - console.log(`[RUNTIME] Generation ${runtime.generation} drained and disposed.`); - } - return result; -} - -async function disposeRuntime( - runtime: TeleBoxRuntime, - reason: string -): Promise { - if (runtime.context.state === "disposed") { - console.log(`[RUNTIME] Generation ${runtime.generation} already disposed before ${reason}.`); - await destroyClient(runtime.client); - return { - completed: true, - timedOut: false, - errors: [], - pendingTasks: 0, - pendingDisposables: 0, - canceledResources: 0, - drainedResources: 0, - timedOutResources: 0, - residualResources: [], - stats: cloneEmptyDrainStats(runtime.context.snapshot().stats), - }; - } - - const drainResult = await drainRuntime(runtime, reason); - try { - await destroyClient(runtime.client); - } catch (error) { - console.error(`[RUNTIME] Failed to destroy generation ${runtime.generation} client:`, error); - throw error; - } - return drainResult; -} - -export function getCurrentRuntime(): TeleBoxRuntime { - if (!currentRuntime) { - throw new Error("TeleBox runtime is not initialized"); - } - return currentRuntime; -} - -export function tryGetCurrentRuntime(): TeleBoxRuntime | null { - return currentRuntime; -} - -export function getCurrentGeneration(): number { - return currentRuntime?.generation ?? 0; -} - -export function isRuntimeTransitioning(): boolean { - return transitionPromise !== null; -} - -export function getCurrentGenerationContext(): GenerationContext { - return getCurrentRuntime().context; -} - -export function tryGetCurrentGenerationContext(): GenerationContext | null { - return currentRuntime?.context ?? null; -} - -export async function getGlobalClient(): Promise { - return getCurrentRuntime().client; -} - -export async function startRuntime(): Promise { - if (currentRuntime?.state === "running") { - return currentRuntime; - } - if (transitionPromise) { - const runtime = await transitionPromise; - if (!runtime || !("client" in runtime)) { - throw new Error("Runtime transition did not produce a running runtime"); - } - return runtime; - } - - transitionPromise = (async () => { - return await startFreshRuntime(); - })(); - - try { - const runtime = await transitionPromise; - if (!runtime || !("client" in runtime)) { - throw new Error("Runtime startup failed"); - } - return runtime; - } finally { - transitionPromise = null; - } -} - -export async function reloadRuntime(): Promise { - if (transitionPromise) { - const runtime = await transitionPromise; - if (!runtime || !("client" in runtime)) { - throw new Error("Runtime reload failed"); - } - return runtime; - } - - transitionPromise = (async () => { - if (!currentRuntime) { - return await startFreshRuntime(); - } - +import { TelegramClient } from "teleproto"; +import { StringSession } from "teleproto/sessions"; +import { getApiConfig } from "./apiConfig"; +import { readAppName } from "./teleboxInfoHelper"; +import { logger } from "./logger"; +import { initializeClientSession } from "./loginManager"; +import { + loadPluginsForRuntime, + unloadPluginsForRuntime, +} from "./pluginManager"; + +import { + createGenerationContext, + type DrainResult, + type GenerationContext, + type GenerationContextSnapshot, + type GenerationResourceStats, + type ResourceResidual, +} from "./generationContext"; + +export type RuntimeState = + | "starting" + | "running" + | "reloading" + | "stopping" + | "draining" + | "failed"; + +export interface TeleBoxRuntime { + generation: number; + state: RuntimeState; + client: TelegramClient; + context: GenerationContext; + signal: AbortSignal; + createdAt: number; + meId?: string; +} + +const RUNTIME_DRAIN_TIMEOUT_MS = 15_000; +const CLIENT_DESTROY_TIMEOUT_MS = 15_000; + +let currentRuntime: TeleBoxRuntime | null = null; +let transitionPromise: Promise | null = null; +let nextGeneration = 1; + +function formatResourceStats(stats: GenerationResourceStats): string { + return Object.entries(stats) + .filter(([, value]) => value.created > 0 || value.active > 0 || value.canceled > 0 || value.timedOut > 0) + .map(([kind, value]) => { + return `${kind}=active:${value.active},created:${value.created},drained:${value.completed},canceled:${value.canceled},timedOut:${value.timedOut}`; + }) + .join("; ") || "none"; +} + +function formatResidualResources(residuals: ResourceResidual[], limit = 12): string { + if (residuals.length === 0) return "none"; + const formatted = residuals.slice(0, limit).map((resource) => { + return `${resource.kind}#${resource.id}:${resource.label}:${resource.state}:${resource.ageMs}ms`; + }); + if (residuals.length > limit) { + formatted.push(`+${residuals.length - limit} more`); + } + return formatted.join("; "); +} + +function logGenerationSnapshot(prefix: string, snapshot: GenerationContextSnapshot): void { + console.log( + `${prefix} generation=${snapshot.generation} state=${snapshot.state} tasks=${snapshot.trackedTasks} disposables=${snapshot.trackedDisposables} stats=[${formatResourceStats(snapshot.stats)}] residual=[${formatResidualResources(snapshot.residualResources)}]` + ); +} + +function logDrainResult(runtime: TeleBoxRuntime, reason: string, result: DrainResult): void { + const residual = formatResidualResources(result.residualResources); + console.log( + `[RUNTIME] Generation ${runtime.generation} ${reason} diagnostics: canceled=${result.canceledResources}, drained=${result.drainedResources}, timedOut=${result.timedOutResources}, residual=${result.residualResources.length}, stats=[${formatResourceStats(result.stats)}], residualDetail=[${residual}]` + ); +} + +function cloneEmptyDrainStats(stats: GenerationResourceStats): GenerationResourceStats { + const cloned = {} as GenerationResourceStats; + for (const [kind, value] of Object.entries(stats)) { + cloned[kind as keyof GenerationResourceStats] = { ...value }; + } + return cloned; +} + +async function withTimeout( + promise: Promise, + ms: number, + label: string +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function createClient(): Promise { + const api = await getApiConfig(); + const proxy = api.proxy; + if (proxy) { + console.log("使用代理连接 Telegram:", proxy); + } + + let connectionRetries = 5; + const envValue = process.env.TB_CONNECTION_RETRIES; + if (envValue) { + const parsed = Number(envValue); + connectionRetries = Number.isInteger(parsed) ? parsed : 5; + } + + console.log( + `连接重试次数: ${connectionRetries}, 可使用环境变量 TB_CONNECTION_RETRIES 设置` + ); + + const client = new TelegramClient( + new StringSession(api.session), + api.api_id!, + api.api_hash!, + { connectionRetries, deviceModel: readAppName(), proxy } + ); + client.setLogLevel(logger.getGramJSLogLevel() as never); + return client; +} + +async function destroyClient(client: TelegramClient): Promise { + await withTimeout(client.destroy(), CLIENT_DESTROY_TIMEOUT_MS, "destroy client"); +} + +async function buildRuntime(): Promise { + const client = await createClient(); + const generation = nextGeneration++; + const context = createGenerationContext(generation); + const runtime: TeleBoxRuntime = { + generation, + state: "starting", + client, + context, + signal: context.signal, + createdAt: Date.now(), + }; + + const sessionInfo = await context.runTask( + async () => await initializeClientSession(client, context), + { label: "runtime:initialize-client-session" } + ); + runtime.meId = sessionInfo.meId; + return runtime; +} + +async function startFreshRuntime(): Promise { + const runtime = await buildRuntime(); + currentRuntime = runtime; + try { + await loadPluginsForRuntime(runtime); + runtime.state = "running"; + return runtime; + } catch (error) { + runtime.state = "failed"; + currentRuntime = null; + runtime.context.abort("Runtime startup failed"); + await runtime.context.dispose(RUNTIME_DRAIN_TIMEOUT_MS).catch((disposeError) => { + console.error("[RUNTIME] Failed to dispose runtime after startup error:", disposeError); + }); + await destroyClient(runtime.client).catch((destroyError) => { + console.error("[RUNTIME] Failed to destroy runtime after startup error:", destroyError); + }); + throw error; + } +} + +async function drainRuntime( + runtime: TeleBoxRuntime, + reason: string, + timeoutMs = RUNTIME_DRAIN_TIMEOUT_MS +): Promise { + runtime.state = "draining"; + console.log(`[RUNTIME] Generation ${runtime.generation} aborting: ${reason}`); + logGenerationSnapshot("[RUNTIME] Pre-drain snapshot", runtime.context.snapshot()); + runtime.context.abort(reason); + const result = await runtime.context.dispose(timeoutMs); + logDrainResult(runtime, reason, result); + if (result.timedOut) { + console.warn( + `[RUNTIME] Generation ${runtime.generation} drain timed out with ${result.pendingTasks} pending tasks and ${result.pendingDisposables} pending disposables.` + ); + } else if (result.errors.length > 0) { + console.warn( + `[RUNTIME] Generation ${runtime.generation} drained with ${result.errors.length} disposable error(s).` + ); + } else { + console.log(`[RUNTIME] Generation ${runtime.generation} drained and disposed.`); + } + return result; +} + +async function disposeRuntime( + runtime: TeleBoxRuntime, + reason: string +): Promise { + if (runtime.context.state === "disposed") { + console.log(`[RUNTIME] Generation ${runtime.generation} already disposed before ${reason}.`); + await destroyClient(runtime.client); + return { + completed: true, + timedOut: false, + errors: [], + pendingTasks: 0, + pendingDisposables: 0, + canceledResources: 0, + drainedResources: 0, + timedOutResources: 0, + residualResources: [], + stats: cloneEmptyDrainStats(runtime.context.snapshot().stats), + }; + } + + const drainResult = await drainRuntime(runtime, reason); + try { + await destroyClient(runtime.client); + } catch (error) { + console.error(`[RUNTIME] Failed to destroy generation ${runtime.generation} client:`, error); + throw error; + } + return drainResult; +} + +export function getCurrentRuntime(): TeleBoxRuntime { + if (!currentRuntime) { + throw new Error("TeleBox runtime is not initialized"); + } + return currentRuntime; +} + +export function tryGetCurrentRuntime(): TeleBoxRuntime | null { + return currentRuntime; +} + +export function getCurrentGeneration(): number { + return currentRuntime?.generation ?? 0; +} + +export function isRuntimeTransitioning(): boolean { + return transitionPromise !== null; +} + +export function getCurrentGenerationContext(): GenerationContext { + return getCurrentRuntime().context; +} + +export function tryGetCurrentGenerationContext(): GenerationContext | null { + return currentRuntime?.context ?? null; +} + +export async function getGlobalClient(): Promise { + return getCurrentRuntime().client; +} + +export async function startRuntime(): Promise { + if (currentRuntime?.state === "running") { + return currentRuntime; + } + if (transitionPromise) { + const runtime = await transitionPromise; + if (!runtime || !("client" in runtime)) { + throw new Error("Runtime transition did not produce a running runtime"); + } + return runtime; + } + + transitionPromise = (async () => { + return await startFreshRuntime(); + })(); + + try { + const runtime = await transitionPromise; + if (!runtime || !("client" in runtime)) { + throw new Error("Runtime startup failed"); + } + return runtime; + } finally { + transitionPromise = null; + } +} + +export async function reloadRuntime(): Promise { + if (transitionPromise) { + const runtime = await transitionPromise; + if (!runtime || !("client" in runtime)) { + throw new Error("Runtime reload failed"); + } + return runtime; + } + + transitionPromise = (async () => { + if (!currentRuntime) { + return await startFreshRuntime(); + } + const oldRuntime = currentRuntime; oldRuntime.state = "reloading"; try { - oldRuntime.context.abort("Runtime reload"); await unloadPluginsForRuntime(oldRuntime); await disposeRuntime(oldRuntime, "Runtime reload"); } catch (error) { - oldRuntime.state = "failed"; - throw error; - } - - const newRuntime = await buildRuntime(); - currentRuntime = newRuntime; - - try { - await loadPluginsForRuntime(newRuntime); - newRuntime.state = "running"; - return newRuntime; - } catch (error) { - console.error("[RUNTIME] Failed to load plugins after reload, keeping runtime alive:", error); - // Keep the new runtime alive: it has a working client, only plugins failed. - // Setting currentRuntime = null previously made the bot completely dead - // (getGlobalClient() throws, all commands fail, no message delivery). - newRuntime.state = "failed"; - currentRuntime = newRuntime; - throw error; - } - })(); - - try { - const runtime = await transitionPromise; - if (!runtime || !("client" in runtime)) { - throw new Error("Runtime reload failed"); - } - return runtime; - } finally { - transitionPromise = null; - } -} - -export async function shutdownRuntime(): Promise { - if (transitionPromise) { - await transitionPromise; - } - if (!currentRuntime) return; - - const runtime = currentRuntime; - runtime.state = "stopping"; - currentRuntime = null; - - runtime.context.abort("Runtime shutdown"); - await unloadPluginsForRuntime(runtime); - await disposeRuntime(runtime, "Runtime shutdown"); -} + oldRuntime.state = "failed"; + throw error; + } + + const newRuntime = await buildRuntime(); + currentRuntime = newRuntime; + + try { + await loadPluginsForRuntime(newRuntime); + newRuntime.state = "running"; + return newRuntime; + } catch (error) { + console.error("[RUNTIME] Failed to load plugins after reload, keeping runtime alive:", error); + // Keep the new runtime alive: it has a working client, only plugins failed. + // Setting currentRuntime = null previously made the bot completely dead + // (getGlobalClient() throws, all commands fail, no message delivery). + newRuntime.state = "failed"; + currentRuntime = newRuntime; + throw error; + } + })(); + + try { + const runtime = await transitionPromise; + if (!runtime || !("client" in runtime)) { + throw new Error("Runtime reload failed"); + } + return runtime; + } finally { + transitionPromise = null; + } +} + +export async function shutdownRuntime(): Promise { + if (transitionPromise) { + await transitionPromise; + } + if (!currentRuntime) return; + + const runtime = currentRuntime; + runtime.state = "stopping"; + currentRuntime = null; + + runtime.context.abort("Runtime shutdown"); + await unloadPluginsForRuntime(runtime); + await disposeRuntime(runtime, "Runtime shutdown"); +}