码桶
发现社区成员的开源项目
plugins.js22.4 KB
/**
* 插件加载器
* --------------------------------------------------
* - 扫描 plugins/ 目录,加载每个插件模块
* - 提供 onMessage(bot, msg) 钩子,消息入站时按已安装且启用的插件顺序分发
* - 内置 sendText(ctx, text) 等工具,供插件直接调用机器人发送消息
*/
const fs = require('fs');
const path = require('path');
const db = require('./db');
const ILink = require('./ilink');
const msgEvents = require('./msg-events');
const PLUGINS_DIR = path.join(__dirname, '..', 'plugins');
// 缓存:bot_id -> [pluginModule,...]
const cache = new Map();
// 每 10 分钟清理一次缓存中已不存在的 bot(防删除 bot 后缓存泄漏)
const _cleanCacheInterval = setInterval(async () => {
try {
const allBots = await db.rows('SELECT id FROM bots');
const validIds = new Set(allBots.map(b => b.id));
for (const botId of cache.keys()) {
if (!validIds.has(botId)) cache.delete(botId);
}
} catch (e) { /* 静默,DB 异常时不清缓存 */ }
}, 10 * 60 * 1000);
_cleanCacheInterval.unref();
/**
* 内置功能 id:始终启用、无需在插件市场安装、也不在市场展示。
* - reply / reminder:仍以模块文件形式存在,但由 loadBuiltinModules 始终加载。
*/
// 注意:smart、memory 已从「插件」改为内置核心功能(固定设置,强制运行),故也列入 BUILTIN_IDS,
// 不再出现在插件市场列表中;其加载由 BUILTIN_MODULE_IDS 强制保证。
const BUILTIN_IDS = ['reply', 'reminder', 'mcp', 'skill', 'smart', 'memory'];
// 以模块文件形式实现、需要始终加载的内置功能
const BUILTIN_MODULE_IDS = ['reply', 'reminder', 'mcp', 'skill', 'memory', 'smart'];
// 内置模块缓存(全局共用,与机器人无关)
let builtinCache = null;
/** 加载始终启用的内置模块(reply / reminder) */
function loadBuiltinModules() {
if (builtinCache) return builtinCache;
const mods = [];
for (const id of BUILTIN_MODULE_IDS) {
const entry = path.join(PLUGINS_DIR, id, 'index.js');
if (!fs.existsSync(entry)) continue;
try {
const mod = require(entry);
if (mod && typeof mod.onMessage === 'function') mods.push(mod);
} catch (err) {
console.error('[builtin] 加载失败:', id, err.message);
}
}
builtinCache = mods;
return mods;
}
/** 扫描 plugins 目录,返回所有可安装插件定义(排除内置功能) */
function loadAllDefinitions() {
const defs = [];
const seen = new Set();
function scanDir(scanPath, dirPrefix = '') {
if (!fs.existsSync(scanPath)) return;
const entries = fs.readdirSync(scanPath, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const entry = path.join(scanPath, e.name, 'index.js');
if (!fs.existsSync(entry)) continue;
try {
const mod = require(entry);
if (mod && mod.meta && mod.meta.id && !BUILTIN_IDS.includes(mod.meta.id)) {
const id = mod.meta.id;
if (seen.has(id)) continue; // 避免重复(marketplace 可能和官方重名)
seen.add(id);
const meta = mod.meta;
defs.push({
...meta,
usage: meta.usage || BUILTIN_USAGE[id] || '',
builtin: !!meta.builtin,
configurable: meta.configurable !== false,
settingsSchema: Array.isArray(meta.settingsSchema) ? meta.settingsSchema : null,
customConfig: meta.customConfig || null,
_module: mod,
dir: dirPrefix + e.name,
});
}
} catch (err) {
console.error('[plugin] 加载失败:', e.name, err.message);
}
}
}
scanDir(PLUGINS_DIR, '');
scanDir(path.join(PLUGINS_DIR, 'market'), 'market/');
return defs;
}
/**
* 取某机器人生效的插件模块(带缓存)
* = 始终启用的内置模块(reply/reminder) + 已安装且启用未过期的市场插件(排除内置)
*/
async function getEnabledModules(botId) {
if (cache.has(botId)) return cache.get(botId);
// 强制安装模式:plugins/ 目录下的所有插件默认已安装并启用,无需在市场点「获取」。
// 仅当用户在 plugins 表中明确停用(enabled=0)时才跳过;其余目录插件一律加载。
const disabledRows = await db.rows('SELECT market_id FROM plugins WHERE bot_id=? AND enabled=0', [botId]);
const disabled = new Set((disabledRows || []).map(r => r.market_id));
const defs = loadAllDefinitions();
const mods = [];
for (const d of defs) {
if (BUILTIN_IDS.includes(d.id)) continue; // 内置功能由 loadBuiltinModules 统一加载,避免重复
if (disabled.has(d.id)) continue; // 用户明确停用:强制安装下仍允许单独停用
const file = path.join(PLUGINS_DIR, d.dir, 'index.js');
if (!fs.existsSync(file)) continue; // 插件文件已被移除,安全跳过,避免逐个消息报错
try {
const mod = require(file);
if (mod && typeof mod.onMessage === 'function') mods.push(mod);
} catch (err) {
console.error('[plugin] 启用插件加载失败:', d.dir, err.message);
}
}
// 市场插件在前,内置 reply/reminder 兜底在后
const all = [...mods, ...loadBuiltinModules()];
cache.set(botId, all);
return all;
}
/** 安装/启用禁用后清空缓存 */
function invalidate(botId) {
if (botId) cache.delete(botId);
else cache.clear();
}
/**
* 热重载:清除 require 缓存与各级内存缓存,使新增/修改的插件立即生效(无需重启服务)。
* 会扫描 plugins 目录下所有 index.js 并删除其 require 缓存,同时重置内置模块缓存与 bot 级缓存。
*/
function reload() {
cache.clear();
builtinCache = null;
if (!fs.existsSync(PLUGINS_DIR)) return;
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const entry = path.join(PLUGINS_DIR, e.name, 'index.js');
if (!fs.existsSync(entry)) continue;
try {
delete require.cache[require.resolve(entry)];
} catch (err) { /* 尚未被加载,忽略 */ }
}
}
/** 内置:各插件的使用说明(id -> 用法文字) */
const BUILTIN_USAGE = {
'reply': '管理员配置关键词/指令后自动回复固定内容',
'rss': '发送「最新」推送订阅源的最新文章',
'reminder': '发送「提醒 时间 内容」设定定时提醒;发送「提醒列表」查看;「删除提醒 编号」取消',
'openclaw': '发送「claw 你的问题」使用 OpenClaw Agent 交互',
'daily-briefing': '发送「早报」或「简报」获取当日新闻摘要',
'delta-password': '发送「三角洲密码」获取当日所有地图密码与位置;「三角洲密码 <地图名>」看单图位置与图片',
'ima-knowledge': '知识库:搜索/列表/导入链接或图片/写入文本;发图片后说「存到知识库」或「知识库 存图」才会保存(不再自动存);笔记:列表/搜索/读取/创建。发送「知识库」或「笔记」查看用法',
'smart': '智能 AI 助手,直接发送自然语言消息,AI 自动判断意图(搜索知识库、设置提醒、看新闻、闲聊等)',
'memory': '用户画像(隐私优先):基于你的聊天记录自动分析「你是怎样的人」并生成画像,注入对话让回复更懂你。发送「同意画像」开启、「我的画像」查看、「导出画像」下载、「拒绝画像」关闭。',
'push': 'Webhook 消息推送(类似 Server 酱/Bark):发送「推送 绑定」生成专属链接,外部系统 POST 即可把消息推到微信;支持智能助手 AI 润色与定时推送。发送「推送」查看用法',
'mail': '邮箱助手:配置 SMTP/IMAP 后可收发邮件。发送「邮件 发送 收件人|主题|正文」发邮件、「邮件 收取」拉取最近邮件;也可直接让智能助手「发邮件/看邮件」;收到新邮件自动在微信提示(可选 AI 摘要)。发送「邮件」查看用法',
'home-assistant': 'Home Assistant 智能家居:对接 HA 平台控制家里设备。发送「设备」查看所有设备,「打开/关闭 XXX」开关设备,「开灯/关灯」一键全屋灯,「传感器/温度」查看环境数据;也可用自然语言「把客厅灯关了」「卧室空调26度」。',
};
/** 内置:分类图标映射 */
const CAT_ICON = {
'AI对话': '[AI]', '信息获取': '[Info]', '工具': '[Tool]', '娱乐': '[Fun]', '消息处理': '[Msg]',
};
/** 生成帮助菜单文本 */
async function buildHelpText(botId) {
const mods = await getEnabledModules(botId);
if (!mods.length) return '[Bot] 当前没有已启用的插件。\n请在插件市场中安装并启用插件。';
// 按分类分组
const groups = {};
for (const mod of mods) {
const meta = mod.meta || {};
const cat = meta.category || '其他';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(meta);
}
const lines = ['-- 功能菜单 --', ''];
for (const [cat, items] of Object.entries(groups)) {
const icon = CAT_ICON[cat] || '[·]';
lines.push(icon + ' ' + cat);
for (const m of items) {
const name = m.name || m.id || '未知';
const usage = BUILTIN_USAGE[m.id] || m.description || '暂无说明';
lines.push(' ▸ ' + name);
lines.push(' ' + usage);
}
lines.push('');
}
lines.push('───────────────');
lines.push('发送「菜单」随时查看');
return lines.join('\n');
}
/**
* 内置功能:用户画像 聊天指令(在 smart 之前拦截,避免被 AI 当作普通对话)
* 处理:同意画像 / 拒绝画像 / 我的画像 / 导出画像 / 删除画像
* @returns {Promise<boolean>} 是否已处理
*/
async function handleMemoryCommand(bot, msg) {
const text = (msg.content || '').trim();
// 仅纯文本指令命中才处理
if (!text) return false;
const memoryMod = require('../plugins/memory');
if (typeof memoryMod.handleCommand !== 'function') return false;
const ctx = createContext(bot, msg);
try {
return await memoryMod.handleCommand(bot, msg, ctx);
} catch (err) {
console.error('[builtin] memory 指令处理异常:', err.message);
return false;
}
}
/**
* 消息入站钩子
* @param {object} bot bots 行(含 bot_token 等)
* @param {{content:string, peer_id:string, context_token:string, msg_type:string}} msg
* 插件返回 true 表示已处理(可用于阻止默认行为),返回 {reply:string} 会自动发送回复
*/
async function onMessage(bot, msg) {
// ===== 内置:发送菜单 =====
const text = (msg.content || '').trim();
if (text === '菜单' || text === '帮助' || text === 'help' || text === 'menu') {
const helpText = await buildHelpText(bot.id);
const ctx = createContext(bot, msg);
await ctx.sendText(helpText);
return true;
}
// ===== 内置:用户画像 聊天指令(优先于 AI)=====
try {
if (await handleMemoryCommand(bot, msg)) return true;
} catch (err) {
msgEvents.push(bot.id, 'error', '用户画像指令处理异常', err.message);
console.error('[builtin] memory 指令处理异常:', err.message);
}
const mods = await getEnabledModules(bot.id);
// 1) 其余插件(含 reply / reminder 等「固定口令 / 关键词」型)优先执行。
// 这样即使开启了智能助手,依赖固定指令 / 关键词触发的插件依然能正常工作,
// 不会被 AI 全部接管。与 dispatchReminder 的「指令优先、AI 兜底」策略一致。
for (const mod of mods) {
if (mod.meta?.id === 'smart') continue;
try {
const ctx = createContext(bot, msg);
const result = await mod.onMessage(msg, ctx);
if (result === true) return true;
if (result && typeof result.reply === 'string' && result.reply) {
await ctx.sendText(result.reply);
}
} catch (err) {
msgEvents.push(bot.id, 'error', `插件 ${mod.meta?.id || '?'} 出错`, err.message);
console.error('[plugin] onMessage 出错:', mod.meta && mod.meta.id, err.message);
}
}
// 2) 没有任何固定口令 / 指令命中 → 交给 smart(AI) 兜底
// (图片识别、语音识别、自然语言对话等能力保持不变)
const smart = mods.find(m => m.meta?.id === 'smart');
if (smart) {
try {
msgEvents.push(bot.id, 'processing', 'AI 思考中...', `消息: ${text.slice(0, 80)}`);
const ctx = createContext(bot, msg);
const result = await smart.onMessage(msg, ctx);
if (result === true) return true;
if (result && typeof result.reply === 'string' && result.reply) {
await ctx.sendText(result.reply);
}
} catch (err) {
msgEvents.push(bot.id, 'error', 'AI 处理失败', err.message);
console.error('[plugin] smart onMessage 出错:', err.message);
}
}
return false;
}
/**
* 提醒触发分发:把提醒内容当作一条「指令/消息」交给插件管线。
* 优先级:先交给指令型插件(rss / 每日简报 等),让「提醒 每天9点 最新」之类能直接触发对应插件;
* 若没有任何指令型插件处理(例如「给我讲个笑话」这类自然语言),再交给 smart(AI) 兜底。
* @param {object} bot bots 行(含 bot_token 等)
* @param {string} content 提醒内容
* @param {string} peerId 接收方
* @param {string} ctxToken 发送所需的 context_token
* @returns {Promise<boolean>} 是否已被插件/AI 处理(已发送)
*/
async function dispatchReminder(bot, content, peerId, ctxToken) {
const msg = { content: String(content || ''), peer_id: peerId, context_token: ctxToken, msg_type: 'text' };
if (!msg.content.trim()) return false;
const mods = await getEnabledModules(bot.id);
const handle = async (mod) => {
try {
const ctx = createContext(bot, msg);
const result = await mod.onMessage(msg, ctx);
if (result === true) return true;
if (result && typeof result.reply === 'string' && result.reply) {
await ctx.sendText(result.reply);
return true;
}
} catch (err) {
console.error('[dispatchReminder] 插件', mod.meta && mod.meta.id, '出错:', err.message);
}
return false;
};
// 1) 指令型插件优先(跳过 smart,避免 AI 吞掉明确指令)
for (const mod of mods) {
if (!mod.meta || mod.meta.id === 'smart') continue;
if (await handle(mod)) return true;
}
// 2) 未命中 → 交给 AI(smart)自然语义处理
const smart = mods.find(m => m.meta?.id === 'smart');
if (smart && await handle(smart)) return true;
return false;
}
/**
* 供第三方插件直接调用智能助手(AI 文本生成 / function calling)。
* 是对 plugins/smart 的 generate() 的便捷封装,插件无需关心 smart 的物理路径。
* @param {object} opts 同 smart.generate:{ botId, userId, prompt, systemPrompt?, ... }
* @returns {Promise<string>} AI 回复文本
*/
async function callAssistant(opts) {
try {
const smart = require('../plugins/smart');
if (smart && typeof smart.generate === 'function') {
return await smart.generate(opts);
}
} catch (e) {
console.error('[callAssistant] 调用智能助手失败:', e.message);
}
throw new Error('智能助手当前不可用');
}
/**
* 为插件构造上下文,包含发送能力
*/
function createContext(bot, msg) {
const il = new ILink(bot);
async function resolveCtx(peer) {
// 优先用当前消息的 context_token(绝大部分消息都有)
if (msg.context_token) return msg.context_token;
// 否则查 DB 获取历史 context_token
const last = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1",
[bot.id]
);
if (last) return last.context_token;
return bot.context_token || null;
}
return {
bot, // 机器人信息
msg, // 当前消息 {content, peer_id, context_token, msg_type}
/** 发送文本给当前消息来源 */
async sendText(text) {
const peer = msg.peer_id;
const ctx = msg.context_token || await resolveCtx(peer);
const textStr = String(text);
const now = Math.floor(Date.now() / 1000);
// 先入库(pending),发送后更新状态
const { lastInsertRowid: rowId } = await db.exec(
"INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, status, created_at) VALUES (?,?,?,?,?,?,?,?)",
[bot.id, 'out', peer, textStr, 'text', ctx || '', 'pending', now]
);
if (!ctx) {
const errMsg = '无可用 context_token(bot 未收到过该用户的消息)';
await db.exec('UPDATE messages SET status=?, error_msg=? WHERE id=?', ['failed', errMsg, rowId]);
msgEvents.push(bot.id, 'outbound_fail', `回复失败: ${textStr.slice(0, 50)}`, errMsg);
throw new Error(errMsg);
}
try {
let resp = await il.sendMessage(peer, ctx, textStr);
let ret = resp.ret ?? -1;
// 会话过期(-14):从 DB 获取最新入站 token 重试一次
if (ret === -14) {
const fresh = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND peer_id=? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1",
[bot.id, peer]
);
if (fresh && fresh.context_token && fresh.context_token !== ctx) {
log('sendText: context_token 过期,用最新入站 token 重试');
ctx = fresh.context_token;
resp = await il.sendMessage(peer, ctx, textStr);
ret = resp.ret ?? -1;
}
}
if (ret !== 0) {
const errMsg = `iLink 返回: ret=${ret} errmsg=${resp.errmsg || resp.msg || JSON.stringify(resp)}`;
await db.exec('UPDATE messages SET status=?, error_msg=? WHERE id=?', ['failed', errMsg, rowId]);
msgEvents.push(bot.id, 'outbound_fail', `回复失败: ${textStr.slice(0, 50)}`, errMsg);
throw new Error('发送失败: ' + errMsg);
}
await db.exec('UPDATE messages SET status=? WHERE id=?', ['ok', rowId]);
msgEvents.push(bot.id, 'outbound_ok', `回复成功: ${textStr.slice(0, 50)}`);
return resp;
} catch (e) {
await db.exec('UPDATE messages SET status=?, error_msg=? WHERE id=?', ['failed', e.message, rowId]);
msgEvents.push(bot.id, 'outbound_fail', `回复失败: ${textStr.slice(0, 50)}`, e.message);
throw e;
}
},
/** 发送图片/文件等(mediaType: image|video|file|voice,filePath 为本地路径) */
async sendMedia(filePath, mediaType, filename, playtime) {
const peer = msg.peer_id;
const ctx = msg.context_token || await resolveCtx(peer);
const now = Math.floor(Date.now() / 1000);
const encContent = `[media:${mediaType}] ${filename || filePath}`;
if (!ctx) {
const errMsg = '无可用 context_token(bot 未收到过该用户的消息)';
await db.exec(
"INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, status, error_msg, created_at) VALUES (?,?,?,?,?,?,?,?,?)",
[bot.id, 'out', peer, encContent, mediaType, '', 'failed', errMsg, now]
);
msgEvents.push(bot.id, 'outbound_fail', `发送${mediaType}失败: ${filename || filePath}`, errMsg);
throw new Error(errMsg);
}
const { lastInsertRowid: rowId } = await db.exec(
"INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, status, created_at) VALUES (?,?,?,?,?,?,?,?)",
[bot.id, 'out', peer, encContent, mediaType, ctx, 'pending', now]
);
try {
const map = { image: 1, video: 2, file: 3, voice: 4 };
const mediaDesc = await il.uploadMediaToCdn(filePath, map[mediaType] || 3, peer);
const doSend = async () => {
if (mediaType === 'image') return await il.sendImage(peer, ctx, mediaDesc);
else if (mediaType === 'video') return await il.sendVideo(peer, ctx, mediaDesc);
else if (mediaType === 'voice') return await il.sendVoice(peer, ctx, mediaDesc, playtime || 0);
else return await il.sendFile(peer, ctx, mediaDesc, filename || '');
};
let resp = await doSend();
let ret = resp.ret ?? -1;
// 会话过期(-14):从 DB 获取最新入站 token 重试一次
if (ret === -14) {
const fresh = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND peer_id=? AND context_token IS NOT NULL ORDER BY id DESC LIMIT 1",
[bot.id, peer]
);
if (fresh && fresh.context_token && fresh.context_token !== ctx) {
log(`sendMedia: context_token 过期,用最新入站 token 重试`);
ctx = fresh.context_token;
resp = await doSend();
ret = resp.ret ?? -1;
}
}
if (ret !== 0) {
const errMsg = `iLink 返回: ret=${ret} errmsg=${resp.errmsg || resp.msg || JSON.stringify(resp)}`;
await db.exec('UPDATE messages SET status=?, error_msg=? WHERE id=?', ['failed', errMsg, rowId]);
msgEvents.push(bot.id, 'outbound_fail', `发送${mediaType}失败: ${filename || filePath}`, errMsg);
throw new Error('发送失败: ' + errMsg);
}
await db.exec('UPDATE messages SET status=? WHERE id=?', ['ok', rowId]);
msgEvents.push(bot.id, 'outbound_ok', `发送${mediaType}成功: ${filename || filePath}`);
return resp;
} catch (e) {
await db.exec('UPDATE messages SET status=?, error_msg=? WHERE id=?', ['failed', e.message, rowId]);
msgEvents.push(bot.id, 'outbound_fail', `发送${mediaType}失败: ${filename || filePath}`, e.message);
throw e;
}
},
};
}
module.exports = { loadAllDefinitions, getEnabledModules, onMessage, dispatchReminder, callAssistant, invalidate, reload, PLUGINS_DIR, BUILTIN_IDS, BUILTIN_USAGE };