码桶
发现社区成员的开源项目
index.js97.3 KB
/**
* 内置插件:智能助手
* --------------------------------------------------
* 无需特定指令前缀,所有消息交给 AI 通过 function calling 自动分析意图。
* AI 会自行判断用户需要搜索知识库、设置提醒、获取新闻还是闲聊。
*
* 支持的意图(通过 function calling):
* - 搜索 IMA 知识库(自然语言查询)
* - 设置/查看/删除定时提醒
* - 获取每日新闻简报
* - 获取随机图片
* - 日常对话
*
* AI 配置使用管理员在后台统一设置的 OpenAI 兼容 API。
* IMA 知识库配置使用每机器人的 plugin_settings。
*/
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const db = require('../../lib/db');
const msgEvents = require('../../lib/msg-events');
const settings = require('../../lib/settings');
const silk = require('silk-wasm');
const stt = require('../../lib/stt');
// ==================== 常量 ====================
const IMA_BASE = 'https://ima.qq.com';
const IMA_API = IMA_BASE + '/openapi/wiki/v1';
const NOTE_API = IMA_BASE + '/openapi/note/v1';
const TMP_DIR = path.join(__dirname, '..', '..', 'data', 'tmp');
const CACHE_TTL = 30 * 60 * 1000; // 简报缓存 30 分钟
const CONV_TTL = 30 * 60 * 1000; // 对话历史 30 分钟
const MAX_TOOL_LOOPS = 5;
// ==================== 提醒调度器(处理智能助手创建的提醒) ====================
// 与 reminder 插件共用 reminders 表:用 global 单例标志保证整个进程只启动一个
// 30s 定时器(谁先被调用谁启动),避免 smart / reminder 双调度器导致重复提醒。
/** reminders 表只建一次,避免每条消息都执行 DDL */
let _remindersTableReady = false;
async function ensureRemindersTable() {
if (_remindersTableReady) return;
await db.exec(`CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id INTEGER NOT NULL,
peer_id VARCHAR(128) NOT NULL,
context_token VARCHAR(256),
content TEXT NOT NULL,
remind_at INTEGER NOT NULL,
repeat_type VARCHAR(16) NOT NULL DEFAULT 'once',
repeat_rule VARCHAR(64) DEFAULT '',
created_at INTEGER NOT NULL,
fired INTEGER NOT NULL DEFAULT 0
)`);
_remindersTableReady = true;
}
function startReminderScheduler() {
if (global.__reminderSchedulerStarted) return;
global.__reminderSchedulerStarted = true;
const ILink = require('../../lib/ilink');
const cron = require('../../lib/cron');
setInterval(async () => {
try {
const now = Math.floor(Date.now() / 1000);
const due = await db.rows(
"SELECT r.*, b.bot_token, b.base_url FROM reminders r JOIN bots b ON r.bot_id = b.id WHERE r.remind_at <= ? AND r.fired = 0 AND COALESCE(r.enabled,1) = 1",
[now]
);
const pluginsLib = require('../../lib/plugins');
for (const r of due) {
try {
const ctxToken = await resolveReminderCtx(r.bot_id, r.peer_id);
if (!ctxToken) continue;
// 联动其他插件:把提醒内容当作指令分发给 rss/每日简报 等插件,
// 未命中任何插件时再退回「⏰ 提醒:内容」纯文本兜底(AI 兜底在 dispatchReminder 内完成)。
const bot = await db.row('SELECT * FROM bots WHERE id=?', [r.bot_id]);
let handled = false;
if (bot) {
handled = await pluginsLib.dispatchReminder(bot, r.content, r.peer_id, ctxToken);
}
if (!handled) {
const il = new ILink({ bot_token: r.bot_token, base_url: r.base_url });
const resp = await il.sendMessage(r.peer_id, ctxToken, '⏰ 提醒:' + r.content);
if ((resp.ret ?? -1) !== 0) continue;
const reminderText = '⏰ 提醒:' + r.content;
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
[r.bot_id, 'out', r.peer_id, reminderText, 'text', ctxToken, now]
);
}
if (r.repeat_type === 'daily') {
await db.exec('UPDATE reminders SET remind_at = remind_at + 86400, fired = 0 WHERE id = ?', [r.id]);
} else if (r.repeat_type === 'weekly') {
await db.exec('UPDATE reminders SET remind_at = remind_at + 604800, fired = 0 WHERE id = ?', [r.id]);
} else if (r.repeat_type === 'interval') {
const m = (r.repeat_rule || '').match(/^(min|hour):(\d+)$/);
const step = m ? (m[1] === 'min' ? parseInt(m[2], 10) * 60 : parseInt(m[2], 10) * 3600) : 60;
const next = now + step;
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [next, r.id]);
} else if (r.repeat_type === 'cron') {
try {
const nxt = cron.nextCronTime(r.repeat_rule, now);
if (nxt) {
await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [nxt, r.id]);
} else {
await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
}
} catch (e) {
console.error('[smart-scheduler] cron 计算失败, 停止任务 id=' + r.id + ':', e.message);
await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
}
} else {
// 一次性任务触发后直接删除,不再保留「已触发」记录
await db.exec('DELETE FROM reminders WHERE id = ?', [r.id]);
}
} catch (err) {
console.error('[smart-scheduler] 提醒 id=' + r.id + ' 发送失败:', err.message);
}
}
} catch (err) {
console.error('[smart-scheduler] 调度错误:', err.message);
}
}, 30000).unref();
console.log('[smart] 提醒调度器已启动');
}
// ============ 主动陪伴调度器 ============
// 已开启「主动陪伴」的用户,在白天时段随机收到一条「朋友式问候」或随机一个插件的推送。
// 需用户主动开启(companion_enabled=1),默认关闭。
const COMPANION_GREETINGS = [
'忙了一天啦,记得喝口水、伸个懒腰休息下哦 🌿',
'刚才突然想起你啦~今天过得还顺利吗?',
'天晴的话,要不要出去走走透透气?☀️',
'夜深了,别熬太晚,早点休息呀 🌙',
'小小提醒:你今天也超棒的,要开开心心的!',
'想你了(bushi)… 有啥想聊的随时找我呀 💬',
'喝水小卫士上线:该补水啦 💧',
'发呆也是种本事,偶尔放空一下也不错~',
];
// 随机推送的插件指令(命中的插件需已安装且启用,否则退回问候)
const COMPANION_PLUGIN_CMDS = ['金价', '三角洲', '早报'];
function startCompanionScheduler() {
if (global.__companionSchedulerStarted) return;
global.__companionSchedulerStarted = true;
const ILink = require('../../lib/ilink');
const pluginsLib = require('../../lib/plugins');
setInterval(async () => {
try {
const now = Math.floor(Date.now() / 1000);
const hour = new Date().getHours();
// 仅在 9:00 - 23:00 推送,不打扰睡眠
if (hour < 9 || hour >= 23) return;
const users = await db.rows(
'SELECT id, companion_last_at FROM users WHERE companion_enabled = 1'
);
for (const u of users) {
try {
// 节流:同一用户至少间隔 4 小时才再推送一次
if (u.companion_last_at && now - u.companion_last_at < 4 * 3600) continue;
const bots = await db.rows('SELECT * FROM bots WHERE user_id = ?', [u.id]);
if (!bots.length) continue;
let pushed = false;
for (const bot of bots) {
const peer = await db.row(
"SELECT peer_id FROM messages WHERE bot_id = ? AND direction = 'in' AND peer_id IS NOT NULL AND peer_id <> '' ORDER BY id DESC LIMIT 1",
[bot.id]
);
if (!peer || !peer.peer_id) continue;
const ctxToken = await resolveReminderCtx(bot.id, peer.peer_id);
if (!ctxToken) continue;
// 随机:50% 推送一个插件,否则发一条问候
let sent = false;
if (Math.random() < 0.5) {
const cmd = COMPANION_PLUGIN_CMDS[Math.floor(Math.random() * COMPANION_PLUGIN_CMDS.length)];
sent = await pluginsLib.dispatchReminder(bot, cmd, peer.peer_id, ctxToken);
}
if (!sent) {
const greet = COMPANION_GREETINGS[Math.floor(Math.random() * COMPANION_GREETINGS.length)];
const il = new ILink(bot);
const resp = await il.sendMessage(peer.peer_id, ctxToken, greet);
sent = (resp.ret ?? -1) === 0;
}
if (sent) {
await db.exec('UPDATE users SET companion_last_at = ? WHERE id = ?', [now, u.id]);
pushed = true;
break; // 一个 bot 推送成功即可
}
}
if (!pushed) {
// 本次上下文失效等导致未推送:顺延 30 分钟再试,避免频繁重试
await db.exec('UPDATE users SET companion_last_at = ? WHERE id = ?', [now - 4 * 3600 + 1800, u.id]);
}
} catch (err) {
console.error('[companion] 用户 id=' + u.id + ' 推送失败:', err.message);
}
}
} catch (err) {
console.error('[companion] 调度错误:', err.message);
}
}, 10 * 60 * 1000).unref();
console.log('[smart] 主动陪伴调度器已启动');
}
// 进程启动时即拉起调度器(与提醒调度器不同,不依赖首条消息触发)
startCompanionScheduler();
async function resolveReminderCtx(botId, peerId) {
if (peerId) {
const lastIn = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND peer_id=? AND direction='in' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
[botId, peerId]
);
if (lastIn && lastIn.context_token) return lastIn.context_token;
}
const latestIn = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
[botId]
);
if (latestIn && latestIn.context_token) return latestIn.context_token;
const bot = await db.row('SELECT context_token FROM bots WHERE id = ?', [botId]);
if (bot && bot.context_token) return bot.context_token;
const lastOut = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='out' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
[botId]
);
return lastOut ? lastOut.context_token : null;
}
// ==================== 对话历史缓存 ====================
const convCache = new Map(); // key: "botId:peerId" -> [{role,content},...]
function getConvKey(botId, peerId) {
return botId + ':' + peerId;
}
function addConvMessage(botId, peerId, role, content) {
const key = getConvKey(botId, peerId);
let arr = convCache.get(key);
if (!arr) {
// 限制最多 500 个会话 key,超出则清理空数组或最旧的
if (convCache.size >= 500) {
let removed = false;
for (const [k, v] of convCache) {
if (!v || v.length === 0) { convCache.delete(k); removed = true; break; }
}
if (!removed) {
// 删除最旧创建的 key
const firstKey = convCache.keys().next().value;
if (firstKey) convCache.delete(firstKey);
}
}
arr = []; convCache.set(key, arr);
}
arr.push({ role, content, ts: Date.now() });
// 只保留最近 20 条 + 清理超时的
const now = Date.now();
while (arr.length > 20) arr.shift();
// 清理过期
for (let i = arr.length - 1; i >= 0; i--) {
if (now - arr[i].ts > CONV_TTL) arr.splice(i, 1);
}
}
function getConvHistory(botId, peerId) {
const key = getConvKey(botId, peerId);
const arr = convCache.get(key);
if (!arr) return [];
const now = Date.now();
// 清理过期
for (let i = arr.length - 1; i >= 0; i--) {
if (now - arr[i].ts > CONV_TTL) arr.splice(i, 1);
}
return arr.slice(-10).map(m => ({ role: m.role, content: m.content }));
}
// ==================== 提醒时间解析(复用 reminder 插件的逻辑) ====================
const WEEKDAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
function adjustHour(hour, period) {
if (period) {
if (period.includes('早') || period.includes('上')) return hour;
if (period.includes('中')) return hour === 12 ? 12 : hour + 12;
if (period.includes('下') || period.includes('晚') || period.includes('傍')) {
if (hour === 12) return 12;
return hour + 12;
}
}
return hour;
}
function parseTime(str) {
str = str.trim();
const now = new Date();
let target = new Date(now);
let repeatType = 'once';
let repeatRule = '';
// 绝对时间: 2026-07-20 14:30
let m = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/);
if (m) {
target = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
// 相对时间: X分钟后 / X小时后
m = str.match(/^(\d+)\s*(分钟|分)\s*后$/);
if (m) { target.setMinutes(target.getMinutes() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
m = str.match(/^(\d+)\s*(小时|时|个?小时)\s*后$/);
if (m) { target.setHours(target.getHours() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
// 每天重复
m = str.match(/^每天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
if (m) {
let hour = adjustHour(parseInt(m[2]), m[1] || '');
let min = m[4] ? parseInt(m[4]) : 0;
target.setHours(hour, min, 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return { target: Math.floor(target.getTime() / 1000), repeatType: 'daily', repeatRule: '' };
}
// 每周重复
m = str.match(/^每周([一二三四五六日天])\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
if (m) {
let wday = WEEKDAY_MAP[m[1]];
let hour = adjustHour(parseInt(m[3]), m[2] || '');
let min = m[5] ? parseInt(m[5]) : 0;
target.setHours(hour, min, 0, 0);
let diff = wday - target.getDay();
if (diff <= 0) diff += 7;
target.setDate(target.getDate() + diff);
if (target <= now) target.setDate(target.getDate() + 7);
return { target: Math.floor(target.getTime() / 1000), repeatType: 'weekly', repeatRule: String(wday) };
}
// 明天 X点
m = str.match(/^明天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
if (m) {
let hour = adjustHour(parseInt(m[2]), m[1] || '');
let min = m[4] ? parseInt(m[4]) : 0;
target.setDate(target.getDate() + 1);
target.setHours(hour, min, 0, 0);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
// 今天/时段 X点
m = str.match(/^(今天|早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
if (m) {
let hour = adjustHour(parseInt(m[2]), m[1] || '');
let min = m[4] ? parseInt(m[4]) : 0;
target.setHours(hour, min, 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
// 仅数字时间 9:00 / 14:30
m = str.match(/^(\d{1,2}):(\d{2})\s*$/);
if (m) {
target.setHours(parseInt(m[1]), parseInt(m[2]), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
}
return null;
}
// ==================== 简报缓存 ====================
let briefingCache = null;
const BRIEFING_API = 'https://v2.xxapi.cn/api/hot60s';
async function fetchBriefing() {
if (briefingCache && Date.now() - briefingCache.time < CACHE_TTL) {
return briefingCache.imageUrl;
}
try {
const { data: resp } = await axios.get(BRIEFING_API, {
timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0' },
});
if (resp.code === 200 && resp.data) {
briefingCache = { imageUrl: resp.data, time: Date.now() };
return resp.data;
}
} catch (e) { /* fall through */ }
return null;
}
// ==================== 全局配置加载 ====================
async function loadSmartConfig() {
const api_base = (await settings.getSetting('ai_api_base', 'https://api.openai.com')).replace(/\/+$/, '').replace(/\/v1\/?$/, '');
const api_key = await settings.getSetting('ai_api_key', '');
const model = await settings.getSetting('ai_model', 'gpt-4o-mini');
// 多接口备选(后台「AI 接口」配置为列表,支持轮询 + 故障转移)
let endpoints = [];
try { endpoints = JSON.parse(await settings.getSetting('ai_endpoints', '[]')) || []; } catch (e) { endpoints = []; }
endpoints = (Array.isArray(endpoints) ? endpoints : [])
.filter(e => e && e.api_base && e.api_key)
.map(e => ({
name: e.name || e.model || '接口',
api_base: normalizeBase(e.api_base),
api_key: e.api_key,
model: (e.model || model).trim(),
}));
return {
api_base, api_key, model,
endpoints,
endpoint_strategy: await settings.getSetting('ai_endpoint_strategy', 'round_robin'),
system_prompt: await settings.getSetting('smart_system_prompt', ''),
max_tokens: parseInt(await settings.getSetting('ai_max_tokens', '2000'), 10) || 2000,
temperature: parseFloat(await settings.getSetting('ai_temperature', '0.7')) || 0.7,
// 语音识别模型(OpenAI 兼容 /v1/audio/transcriptions),默认 whisper-1
transcribe_model: await settings.getSetting('ai_transcribe_model', 'whisper-1'),
// 语音识别模式:local=本地 Whisper(纯本地无外部接口,默认);ai=OpenAI 兼容接口;mimo=米米MIMO语音识别
stt_mode: await settings.getSetting('ai_stt_mode', 'local'),
// 本地 Whisper 模型(HuggingFace 仓库 ID),默认 multilingual base
stt_model: await settings.getSetting('ai_stt_model', 'Xenova/whisper-base'),
// MIMO 语音识别(api.xiaomimimo.com)配置
stt_mimo_api_key: await settings.getSetting('ai_stt_mimo_api_key', ''),
stt_mimo_base: (await settings.getSetting('ai_stt_mimo_base', 'https://api.xiaomimimo.com')).replace(/\/+$/, ''),
stt_mimo_model: await settings.getSetting('ai_stt_mimo_model', 'mimo-v2.5-asr'),
stt_mimo_lang: await settings.getSetting('ai_stt_mimo_lang', 'zh'),
// 图片生成(独立 API 配置,不引用 AI 对话接口)
image_gen_enabled: await settings.getSetting('ai_image_gen_enabled', '1'),
image_gen_api_base: (await settings.getSetting('ai_image_gen_api_base', '')).replace(/\/+$/, ''),
image_gen_api_key: await settings.getSetting('ai_image_gen_api_key', ''),
image_gen_model: await settings.getSetting('ai_image_gen_model', 'dall-e-3'),
image_gen_size: await settings.getSetting('ai_image_gen_size', '1024x1024'),
};
}
async function loadSmartBotConfig(botId) {
const rows = await db.rows(
'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
[botId, 'smart']
);
const cfg = {};
rows.forEach(r => { cfg[r.config_key] = r.config_value; });
return cfg;
}
/** 统一归一化 API Base(去掉结尾 / 与 /v1) */
function normalizeBase(s) {
return String(s || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
}
/**
* 判断某份配置是否可用(有任一可用模型)。兼容两种形态:
* - 站长模型:单组 { api_base, api_key, model }
* - 自定义模型:{ models: [ {...}, ... ] }
*/
function cfgHasAI(cfg) {
if (cfg.api_base && cfg.api_key) return true;
if (Array.isArray(cfg.models) && cfg.models.some(m => m.api_base && m.api_key)) return true;
return false;
}
// botId → 机器人拥有者的 ai_persona(缓存 10 秒,用户切换后快速生效)
const botPersonaCache = new Map();
async function getBotOwnerPersona(botId) {
const cached = botPersonaCache.get(botId);
if (cached && Date.now() - cached.ts < 10000) return cached.persona;
const row = await db.row(
'SELECT u.ai_persona FROM users u JOIN bots b ON u.id = b.user_id WHERE b.id = ?',
[botId]
);
const persona = (row && row.ai_persona) || '';
botPersonaCache.set(botId, { persona, ts: Date.now() });
return persona;
}
/**
* 解析智能助手的 AI 接口来源(模型选择):
* - ai_source = 'owner'(默认)→ 使用管理员在后台统一配置的「站长模型」(全局 ai_api_base / ai_api_key / ai_model ...)
* - ai_source = 'custom' → 使用机器人自定义的「自定义模型」列表(支持多个,自动轮询)
* - 兼容旧版:若未设置 ai_source 但 use_custom_ai=1 且旧单组 api_base/key 齐全 → 视为 custom(单模型)
* 自定义模型列表来自 plugin_settings 的 ai_custom_models(JSON 数组),结构:
* [ { name, api_base, api_key, model }, ... ]
* 若选择了 custom 却没有任何可用自定义模型 → 自动回退到站长模型。
*/
async function resolveSmartConfig(botId) {
const global = await loadSmartConfig();
const bot = await loadSmartBotConfig(botId);
const source = bot.ai_source || (bot.use_custom_ai === '1' ? 'custom' : 'owner');
const userPersona = await getBotOwnerPersona(botId);
let result;
if (source === 'custom') {
const list = [];
// ① 兼容旧版单组自定义配置(use_custom_ai 时代)
if (bot.ai_api_base && bot.ai_api_key) {
list.push({
name: '默认模型',
api_base: normalizeBase(bot.ai_api_base),
api_key: bot.ai_api_key,
model: (bot.ai_model || global.model || 'gpt-4o-mini').trim(),
});
}
// ② 新版多模型列表(自定义模型 + 轮询)
if (bot.ai_custom_models) {
try {
const arr = JSON.parse(bot.ai_custom_models);
if (Array.isArray(arr)) {
for (const m of arr) {
if (m && m.api_base && m.api_key) {
list.push({
name: m.name || m.model || '自定义模型',
api_base: normalizeBase(m.api_base),
api_key: m.api_key,
model: (m.model || global.model || 'gpt-4o-mini').trim(),
});
}
}
}
} catch (e) {
console.error('[smart] 解析 ai_custom_models 失败:', e.message);
}
}
if (list.length) {
result = {
source: 'custom',
custom: true,
models: list,
system_prompt: bot.ai_system_prompt || '',
max_tokens: parseInt(bot.ai_max_tokens, 10) || global.max_tokens || 2000,
temperature: parseFloat(bot.ai_temperature) || global.temperature || 0.7,
transcribe_model: global.transcribe_model || 'whisper-1',
stt_mode: global.stt_mode || 'local',
stt_model: global.stt_model || 'Xenova/whisper-base',
stt_mimo_api_key: global.stt_mimo_api_key || '',
stt_mimo_base: global.stt_mimo_base || 'https://api.xiaomimimo.com',
stt_mimo_model: global.stt_mimo_model || 'mimo-v2.5-asr',
stt_mimo_lang: global.stt_mimo_lang || 'zh',
};
}
// 选了 custom 但没有任何可用自定义模型 → 回退站长模型
}
if (!result) {
// 全局「站长模型」若配置了多接口列表,则注入 models 供轮询调用;否则回退单组兜底
result = { ...global, source: 'owner', custom: false };
if (global.endpoints && global.endpoints.length) {
result.models = global.endpoints;
result.endpoint_strategy = global.endpoint_strategy || 'round_robin';
}
}
result.persona = userPersona;
return result;
}
// 开源版:Token 不限额
async function getUserTokenInfo(userId) {
return { used: 0, limit: Number.MAX_SAFE_INTEGER };
}
async function addUserTokens(userId, tokens) {
await db.exec('UPDATE users SET ai_tokens_used = ai_tokens_used + ? WHERE id = ?', [tokens, userId]);
const d = new Date();
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
try {
await db.exec(
'INSERT INTO token_usage(user_id, day, tokens) VALUES(?, ?, ?) ON CONFLICT(user_id, day) DO UPDATE SET tokens = tokens + excluded.tokens',
[userId, day, tokens]
);
} catch (e) { /* 热力图统计为非关键路径,失败忽略 */ }
}
/**
* 图片生成(OpenAI 兼容 /v1/images/generations)。
* @param {object} opts { botId, userId?, prompt, size?, quality?, n? }
* @returns {Promise<{images:Array<{url?:string,b64_json?:string}>,quota:{used,limit}}>}
*/
async function callImageGeneration(opts) {
const { botId, userId, prompt } = opts || {};
if (!botId) throw new Error('缺少 botId');
if (!prompt) throw new Error('缺少图片描述');
// 使用独立的生图 API 配置(不引用 AI 对话接口)
const global = await loadSmartConfig();
if (!global.image_gen_enabled || global.image_gen_enabled !== '1') throw new Error('图片生成功能未启用');
if (!global.image_gen_api_base || !global.image_gen_api_key) throw new Error('生图 API 未配置(缺少 API Base 或 API Key)');
const imageModel = global.image_gen_model || 'dall-e-3';
const imageSize = opts.size || global.image_gen_size || '1024x1024';
const imageQuality = opts.quality || 'standard';
const n = opts.n || 1;
const base = global.image_gen_api_base.replace(/\/+$/, '');
const reqBody = {
model: imageModel,
prompt,
n,
size: imageSize,
quality: imageQuality,
// 要求返回 base64 内联数据:图片字节走生图 API 网关(可访问),
// 避免再去下载被墙的图床域名(如 cloudflarer2.nananobanana.com)导致转发失败。
response_format: 'b64_json',
};
console.log('[smart] 图片生成请求:', JSON.stringify({ api_base: base, model: imageModel, size: imageSize, prompt: prompt.slice(0, 80) }));
// 注意:原生 fetch 不支持 timeout 选项(会被忽略),大图生成常需 80~150s,
// 这里改用 axios 并设 3 分钟真实超时,避免上游网关默认超时导致"服务暂时不可用"。
const t0 = Date.now();
let resp;
try {
const { default: axios } = await import('axios');
const res = await axios.post(base + '/v1/images/generations', reqBody, {
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + global.image_gen_api_key },
timeout: 180000,
validateStatus: () => true,
});
console.log('[smart] 图片生成响应:', res.status, '耗时', ((Date.now() - t0) / 1000).toFixed(1) + 's');
if (res.status < 200 || res.status >= 300) {
const errBody = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || {});
throw new Error(`HTTP ${res.status} ${errBody.slice(0, 200)}`);
}
resp = res.data;
} catch (e) {
const secs = ((Date.now() - t0) / 1000).toFixed(1);
if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || '')) {
throw new Error(`生图超时(已等待 ${secs}s,大尺寸图片生成较慢,请稍后重试或换用 1024x1024 尺寸)`);
}
throw new Error('图片生成失败: ' + e.message);
}
// 记录生图日志(开源版不扣额度)
if (userId) {
await db.logImageGen(userId, prompt, imageModel, imageSize, n);
}
const images = (resp.data || []).map(d => ({ url: d.url, b64_json: d.b64_json }));
return { images, quota: null };
}
// ==================== IMA 配置和 API ====================
// 复用 ima-knowledge 插件导出的公共函数,避免两套 IMA 实现分叉
const imaPlugin = require('../ima-knowledge');
async function loadImaConfig(botId) {
const rows = await db.rows(
'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
[botId, 'ima-knowledge']
);
const cfg = {};
rows.forEach(r => { cfg[r.config_key] = r.config_value; });
return { client_id: cfg.client_id || '', api_key: cfg.api_key || '' };
}
// ==================== Function 定义 ====================
const TOOLS = [
{
type: 'function',
function: {
name: 'search_knowledge',
description: '搜索用户的IMA知识库。当用户想查找某个文档、笔记、记录、合同、会议纪要、供应商资料、工作文件等任何知识库中的内容时调用此函数。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词,提取用户问题中最关键的信息作为搜索词' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'set_reminder',
description: '设置一个定时提醒。当用户表达"提醒我"、"帮我设置提醒"、"记住"、"X分钟/小时后叫我/提醒"、"明天X点叫我"、"每天X点提醒"等意图时调用。',
parameters: {
type: 'object',
properties: {
time_desc: { type: 'string', description: '时间描述。必须转换为标准格式,如"5分钟后"、"30分钟后"、"1小时后"、"明天上午9点"、"下午3点"、"每天8点"、"每周一9点"、"2026-12-31 14:00"' },
content: { type: 'string', description: '提醒的具体内容' }
},
required: ['time_desc', 'content']
}
}
},
{
type: 'function',
function: {
name: 'list_reminders',
description: '查看当前所有待执行的提醒列表。当用户问"有什么提醒"、"查看提醒"、"我的提醒"、"还有哪些提醒"时调用。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'delete_reminder',
description: '删除一个提醒。当用户说"取消提醒"、"删除提醒"、"去掉X号提醒"时调用。如果用户未指定编号,先调用list_reminders让用户确认。',
parameters: {
type: 'object',
properties: {
id: { type: 'integer', description: '要删除的提醒编号ID' }
},
required: ['id']
}
}
},
{
type: 'function',
function: {
name: 'get_daily_briefing',
description: '获取今日新闻简报。当用户说"早报"、"新闻"、"简报"、"今天有什么新闻"、"每日新闻"时调用。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'get_random_image',
description: '给用户发送一张随机美图/图片。当用户说"来张图"、"随机图片"、"发张美图"、"来点图片"时调用。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'create_note',
description: '在IMA知识库中创建一篇新笔记。当用户说"记录一下"、"帮我记一下"、"写个笔记"、"保存到知识库"、"备忘"、"新建笔记"时调用。',
parameters: {
type: 'object',
properties: {
content: { type: 'string', description: '笔记正文内容,使用Markdown格式' },
title: { type: 'string', description: '笔记标题(可选),如果用户没有提供就用内容第一句作为标题' }
},
required: ['content']
}
}
},
{
type: 'function',
function: {
name: 'save_url_to_knowledge',
description: '将一个或多个网页链接/公众号文章/图片链接保存到IMA知识库。图片链接会作为图片文件存入,网页链接作为网页导入。当用户说"把这个链接保存"、"收藏这个网页"、"保存这篇文章到知识库"、"存这张图到知识库"时调用。',
parameters: {
type: 'object',
properties: {
urls: { type: 'array', items: { type: 'string' }, description: '要保存的URL列表' },
knowledge_base_id: { type: 'string', description: '目标知识库ID(可选),不填则保存到第一个可用知识库' }
},
required: ['urls']
}
}
},
{
type: 'function',
function: {
name: 'write_knowledge',
description: '将一段文本/内容直接写入 IMA 知识库(区别于保存网页链接)。当用户说"把这段话存到知识库"、"记到知识库"、"保存这段文字"、"写入知识库"等意图时调用。',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: '内容标题' },
content: { type: 'string', description: '要写入的文本内容' }
},
required: ['title', 'content']
}
}
},
{
type: 'function',
function: {
name: 'save_image_to_knowledge',
description: '把用户最近发送的图片保存到 IMA 知识库。仅当用户明确表示要保存图片时才调用,例如"把(刚才/这张)图片存到知识库"、"保存这张图片"、"存图到知识库"、"收藏这张图"。如果用户只是发图片没有说要保存,不要调用。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'search_note',
description: '搜索用户的 IMA 笔记。当用户想查找某篇笔记,或问"我有没有记过XX"、"找一下那篇笔记"时调用。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'read_note',
description: '读取某篇 IMA 笔记的完整内容。当用户说"读一下那篇笔记"、"给我看XX笔记全文"并提供了笔记ID时调用;如果不知道ID,先调用 search_note 获取。',
parameters: {
type: 'object',
properties: {
note_id: { type: 'string', description: '笔记ID,可从 search_note 结果中获取' }
},
required: ['note_id']
}
}
},
{
type: 'function',
function: {
name: 'generate_image',
description: '使用 AI 生成一张图片。当用户说"帮我画一张"、"生成一张图"、"画个XX"、"做张XX的图"、"生成图片"、"AI画图"、"绘图"等想要创作图片的意图时调用。描述要详细、具体(风格、场景、色彩、氛围等)。每次生成会消耗用户生图额度。',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string', description: '图片生成的详细描述(英文效果更好),包含主题、风格、场景、色彩、氛围等细节' },
size: { type: 'string', description: '图片尺寸,可选 1024x1024、1792x1024、1024x1792,默认 1024x1024' },
},
required: ['prompt']
}
}
}
];
// ==================== 通用插件工具对接 ====================
/**
* 收集当前机器人所有已启用插件声明的 AI 工具(通用互通协议)。
* 任何插件只要在 meta.aiTools(或导出 aiTools)里声明 OpenAI function schema,
* 并导出 handleAiTool(name, args, ctx),就会被自动注册进 smart 的 function calling,
* 无需修改 smart 本身。详见 docs/插件开发指南.md「对接智能助手」。
* @returns {Promise<{tools:Array, handlers:Object}>}
*/
async function collectPluginTools(botId) {
try {
const pluginsLib = require('../../lib/plugins');
const mods = await pluginsLib.getEnabledModules(botId);
const tasks = mods.map(async (mod) => {
const out = { tools: [], handlers: {} };
if (!mod || !mod.meta || mod.meta.id === 'smart') return out; // 跳过自己
const handler = mod.handleAiTool || mod.executeAiTool;
// 静态 aiTools 声明(meta.aiTools 数组)
const declared = mod.meta.aiTools || mod.aiTools;
if (Array.isArray(declared) && declared.length > 0 && typeof handler === 'function') {
for (const t of declared) {
const fnName = t && t.function && t.function.name;
if (!fnName || out.handlers[fnName]) continue;
out.tools.push(t);
out.handlers[fnName] = handler;
}
}
// 动态 aiTools(getAiTools(botId) → Promise<Array>)—— MCP、Skill 等
if (typeof mod.getAiTools === 'function' && typeof handler === 'function') {
try {
const dynamicTools = await mod.getAiTools(botId);
if (Array.isArray(dynamicTools) && dynamicTools.length > 0) {
for (const t of dynamicTools) {
const fnName = t && t.function && t.function.name;
if (!fnName || out.handlers[fnName]) continue;
out.tools.push(t);
out.handlers[fnName] = handler;
}
}
} catch (e) {
console.error('[smart] 收集动态 AI 工具失败 (' + mod.meta.id + '):', e.message);
}
}
return out;
});
const results = await Promise.all(tasks);
const tools = [];
const handlers = {};
for (const r of results) {
for (const t of r.tools) {
const fnName = t && t.function && t.function.name;
if (fnName && !handlers[fnName]) {
tools.push(t);
handlers[fnName] = r.handlers[fnName];
}
}
}
return { tools, handlers };
} catch (e) {
console.error('[smart] 收集插件 AI 工具失败:', e.message);
return { tools: [], handlers: {} };
}
}
/**
* 收集所有插件的系统提示扩充(如 Skill 插件的 getCombinedPrompt)。
* 任何已启用插件只要导出 getCombinedPrompt(botId, peerId) → string,就会被附加到 AI 系统提示中。
* peerId 用于按用户注入(如用户画像)。
*/
async function collectPluginPrompts(botId, peerId) {
try {
const pluginsLib = require('../../lib/plugins');
const mods = await pluginsLib.getEnabledModules(botId);
const tasks = [];
for (const mod of mods) {
if (!mod || !mod.meta) continue;
if (mod.meta.id === 'smart' || mod.meta.id === 'mcp') continue; // 跳过自己 & MCP(只提供工具,不改变 prompt)
if (typeof mod.getCombinedPrompt === 'function') {
tasks.push(
Promise.resolve()
.then(() => mod.getCombinedPrompt(botId, peerId))
.then(p => (p && typeof p === 'string' && p.trim()) ? p.trim() : null)
.catch(e => { console.error('[smart] 收集插件提示失败 (' + mod.meta.id + '):', e.message); return null; })
);
}
}
const results = await Promise.all(tasks);
return results.filter(Boolean).join('\n');
} catch (e) {
console.error('[smart] 收集插件提示失败:', e.message);
return '';
}
}
// ==================== 工具执行器 ====================
async function executeTool(toolName, args, ctx, pluginHandlers) {
const botId = ctx.bot.id;
const peerId = ctx.msg.peer_id;
switch (toolName) {
case 'search_knowledge': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return '知识库功能尚未配置。请在机器人管理面板中设置 IMA 知识库的 Client ID 和 API Key。获取方式:登录 ima.qq.com → 设置 → API 管理。';
}
const query = (args.query || '').trim();
if (!query) return '请提供搜索关键词。';
try {
const bases = await imaPlugin.searchKnowledgeBases(imaCfg, query);
if (!bases.length) return '未找到匹配的知识库,请尝试其他关键词。';
const kbId = bases[0].id;
const kbName = bases[0].name || kbId;
const results = await imaPlugin.searchInKnowledge(imaCfg, kbId, query);
if (!results.length) return `在知识库「${kbName}」中未找到与「${query}」相关的内容。`;
const items = results.slice(0, 5).map((item, i) => {
let snippet = (item.highlight_content || '').replace(/<\/?em>/g, '').replace(/\s+/g, ' ').trim();
if (snippet.length > 150) snippet = snippet.slice(0, 150) + '…';
return `${i + 1}. 【${item.title || '无标题'}】${snippet ? '\n ' + snippet : ''}`;
});
let reply = `[知识库] 在「${kbName}」中搜索「${query}」,找到 ${results.length} 条结果:\n\n${items.join('\n\n')}`;
if (results.length > 5) reply += `\n\n… 还有 ${results.length - 5} 条结果。`;
return reply;
} catch (e) {
return '搜索知识库时出错:' + e.message;
}
}
case 'set_reminder': {
const timeDesc = (args.time_desc || '').trim();
const content = (args.content || '').trim();
if (!timeDesc || !content) return '请同时提供时间和提醒内容。';
const parsed = parseTime(timeDesc);
if (!parsed) return `无法解析时间「${timeDesc}」。支持格式:5分钟后、1小时后、明天9点、下午3点、每天8点、每周一9点、2026-07-20 14:30`;
const now = Math.floor(Date.now() / 1000);
if (parsed.target <= now) return `时间「${timeDesc}」已过,请设置未来时间。`;
const ctxToken = ctx.msg.context_token || null;
await db.exec(
'INSERT INTO reminders (bot_id, peer_id, context_token, content, remind_at, repeat_type, repeat_rule, created_at, fired) VALUES (?,?,?,?,?,?,?,?,0)',
[botId, peerId, ctxToken, content, parsed.target, parsed.repeatType, parsed.repeatRule || '', now]
);
const dt = new Date(parsed.target * 1000);
const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const tag = parsed.repeatType === 'daily' ? ',每天重复' : parsed.repeatType === 'weekly' ? ',每周重复' : '';
return `[OK] 提醒已设置:${ds}${tag}\n内容:${content}`;
}
case 'list_reminders': {
const items = await db.rows(
'SELECT id, content, remind_at, repeat_type FROM reminders WHERE bot_id=? AND peer_id=? AND fired=0 ORDER BY remind_at ASC LIMIT 20',
[botId, peerId]
);
if (!items.length) return '当前没有待执行的提醒。';
const lines = items.map((r, i) => {
const dt = new Date(r.remind_at * 1000);
const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const tag = r.repeat_type === 'daily' ? '[每天] ' : r.repeat_type === 'weekly' ? '[每周] ' : '';
return `${r.id}. ${tag}${ds} — ${r.content}`;
});
return '[提醒] 待执行提醒:\n' + lines.join('\n') + '\n\n发送「删除提醒 编号」可取消。';
}
case 'delete_reminder': {
const rid = parseInt(args.id, 10);
if (!rid || rid < 1) return '请提供有效的提醒编号。';
const item = await db.row('SELECT id, content FROM reminders WHERE id=? AND bot_id=? AND peer_id=?', [rid, botId, peerId]);
if (!item) return `未找到编号为 ${rid} 的提醒。`;
await db.exec('DELETE FROM reminders WHERE id = ?', [rid]);
return `[OK] 已删除提醒 #${rid}:「${item.content}」`;
}
case 'get_daily_briefing': {
try {
const imageUrl = await fetchBriefing();
if (!imageUrl) {
return '暂时无法获取新闻简报,请稍后再试。';
}
const ext = imageUrl.split('?')[0].split('.').pop().toLowerCase() || 'jpg';
const tmpName = 'briefing_smart_' + Date.now() + '.' + ext;
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
const tmpPath = path.join(TMP_DIR, tmpName);
const response = await axios.get(imageUrl, {
timeout: 15000, responseType: 'arraybuffer',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://v2.xxapi.cn/',
},
});
const buf = Buffer.from(response.data);
if (buf.length < 100) {
console.error('[smart] 简报图片过小:', buf.toString('utf-8').substring(0, 200));
return '简报图片获取异常,请稍后再试。';
}
fs.writeFileSync(tmpPath, buf);
try {
await ctx.sendMedia(tmpPath, 'image');
} catch (e) {
console.error('[smart] sendMedia 简报失败:', e.message);
return '简报图片发送失败,请稍后再试。';
}
try { fs.unlinkSync(tmpPath); } catch (_) { /* ignore */ }
return '已发送今日新闻简报图片给你!';
} catch (e) {
return '获取新闻简报时出错:' + e.message;
}
}
case 'get_random_image': {
try {
const { data: resp } = await axios.get('https://api.unmz.net/free/api/images/girl/getRandomGirlUrl', {
timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0' },
});
const urls = resp && resp.data;
if (!urls || !Array.isArray(urls) || urls.length === 0) {
return '暂时未能获取到图片,请稍后再试。';
}
const imgUrl = urls[0];
const ext = imgUrl.split('?')[0].split('.').pop().slice(0, 4) || 'jpg';
const tmpName = 'randimg_smart_' + Date.now() + '.' + ext;
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
const tmpPath = path.join(TMP_DIR, tmpName);
const { data: imgBuf } = await axios.get(imgUrl, {
timeout: 15000, responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0' },
});
fs.writeFileSync(tmpPath, Buffer.from(imgBuf));
try {
await ctx.sendMedia(tmpPath, 'image');
} catch (e) {
console.error('[smart] sendMedia 失败:', e.message);
}
try { fs.unlinkSync(tmpPath); } catch (_) { /* ignore */ }
return '已发送一张随机美图给你!';
} catch (e) {
return '获取图片时出错:' + e.message;
}
}
case 'create_note': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
const content = (args.content || '').trim();
if (!content) return '请提供要记录的笔记内容。';
const title = args.title || content.split(/[\n\r]/)[0].slice(0, 50);
try {
// 1) 创建笔记
const note = await imaPlugin.createNote(imaCfg, content);
const docId = note.doc_id || note.note_id;
// 2) 获取可用知识库,绑定笔记到知识库
let kbName = '';
if (docId) {
const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
if (bases.length) {
const kb = bases[0];
kbName = kb.name || '';
await imaPlugin.addKnowledge(imaCfg, kb.id, title, 11, { note_info: { content_id: docId } });
}
}
const kbSuffix = kbName ? `\n已同步到知识库:${kbName}` : '';
return `[OK] 笔记已创建成功!\n标题:${title}${kbSuffix}`;
} catch (e) {
return '创建笔记失败:' + e.message;
}
}
case 'save_url_to_knowledge': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
const rawUrls = args.urls || [];
// 处理 args.urls 可能是字符串的情况(AI 有时不严格输出数组)
const urls = Array.isArray(rawUrls) ? rawUrls : (typeof rawUrls === 'string' ? [rawUrls] : []);
const validUrls = urls.map(u => String(u).trim()).filter(u => u && /^https?:\/\/.+/.test(u));
if (validUrls.length === 0) return '请提供有效的网页链接(以 http:// 或 https:// 开头)。';
try {
// 获取可用知识库
let kbId = args.knowledge_base_id;
if (!kbId) {
const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
if (!bases.length) return '没有找到可用的知识库,请先在 IMA 中创建一个知识库。';
kbId = bases[0].id;
}
// 图片URL → 作为图片文件存入;其余 → 作为网页链接导入
const imageUrls = validUrls.filter(u => imaPlugin.isImageUrl(u));
const linkUrls = validUrls.filter(u => !imaPlugin.isImageUrl(u));
if (linkUrls.length) await imaPlugin.importUrls(imaCfg, kbId, linkUrls);
let imgSaved = 0;
for (const u of imageUrls) {
try {
const img = await imaPlugin.downloadImageFromUrl(u);
await imaPlugin.addKnowledgeImage(imaCfg, kbId, img);
imgSaved++;
} catch (e) { /* 单张失败不影响其他 */ }
}
const bits = [];
if (linkUrls.length) bits.push(linkUrls.length + ' 个链接');
if (imgSaved) bits.push(imgSaved + ' 张图片');
return `[OK] 已保存到知识库(${bits.join(',') || '0'})!`;
} catch (e) {
return '保存失败:' + e.message;
}
}
case 'write_knowledge': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
const title = (args.title || '').trim();
const content = (args.content || '').trim();
if (!content) return '请提供要写入的内容。';
try {
const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
if (!bases.length) return '没有可用的知识库,请先在 IMA 中创建一个知识库。';
const kb = bases[0];
const kbName = kb.name || kb.id;
await imaPlugin.addKnowledgeText(imaCfg, kb.id, title || content.slice(0, 20), content);
return `[OK] 已写入知识库「${kbName}」!`;
} catch (e) {
return '写入知识库失败:' + e.message;
}
}
case 'save_image_to_knowledge': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
try {
const img = await imaPlugin.loadLastInboundImage(ctx.bot, peerId);
if (!img || !img.buffer || !img.buffer.length) {
return '没有找到你最近发送的图片。请先发送一张图片,再说"存到知识库"。';
}
const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
if (!bases.length) return '没有可用的知识库,请先在 IMA 中创建一个知识库。';
const kb = bases[0];
await imaPlugin.addKnowledgeImage(imaCfg, kb.id, img);
return `[OK] 已把图片存入知识库「${kb.name || kb.id}」!`;
} catch (e) {
return '保存图片到知识库失败:' + e.message;
}
}
case 'search_note': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
const query = (args.query || '').trim();
if (!query) return '请提供搜索关键词。';
try {
const notes = await imaPlugin.searchNotes(imaCfg, query);
if (!notes.length) return `未找到与「${query}」相关的笔记。`;
const items = notes.slice(0, 8).map((n, i) =>
`${i + 1}. ${n.title || n.name || '无标题'} (id: ${n.note_id || n.id || ''})`
);
return `[Notes] 笔记搜索「${query}」找到 ${notes.length} 篇:\n${items.join('\n')}\n\n可调用 read_note 并传入 id 读取某篇全文。`;
} catch (e) {
return '搜索笔记失败:' + e.message;
}
}
case 'read_note': {
const imaCfg = await loadImaConfig(botId);
if (!imaCfg.client_id || !imaCfg.api_key) {
return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
}
const noteId = (args.note_id || '').trim();
if (!noteId) return '请提供笔记ID。可先调用 search_note 查找。';
try {
const note = await imaPlugin.getNote(imaCfg, noteId);
const title = note.title || (note.doc && note.doc.basic_info && note.doc.basic_info.title) || '无标题';
const content = note.content || (note.doc && note.doc.content) || '';
if (!content) return `[Note] 《${title}》没有可读取的正文内容。`;
if (content.length > 1500) {
return `[Note] 《${title}》(共 ${content.length} 字,节选前 1500 字)\n\n${content.slice(0, 1500)}\n\n… 内容较长已截断,可在 IMA 中查看完整笔记。`;
}
return `[Note] 《${title}》\n\n${content}`;
} catch (e) {
return '读取笔记失败:' + e.message;
}
}
case 'generate_image': {
return await handleGenerateImageTool(args, ctx);
}
default:
// 交给声明了该工具的第三方插件处理(通用互通协议)
if (pluginHandlers && typeof pluginHandlers[toolName] === 'function') {
try {
const r = await pluginHandlers[toolName](toolName, args, ctx);
return String(r == null ? '' : r);
} catch (e) {
console.error('[smart] 插件工具执行失败:', toolName, e.message);
return '工具「' + toolName + '」执行失败:' + e.message;
}
}
return `未知工具:${toolName}`;
}
}
/** 图片生成工具处理(单独导出,方便复用) */
async function handleGenerateImageTool(args, ctx) {
const botId = ctx.bot.id;
const userId = ctx.bot.user_id;
const prompt = (args.prompt || '').trim();
const size = args.size || '1024x1024';
if (!prompt) return '请提供图片描述。';
// 检查生图功能是否启用
const globalCfg = await loadSmartConfig();
if (globalCfg.image_gen_enabled !== '1') return '图片生成功能未启用,请联系管理员开启。';
try {
const result = await callImageGeneration({ botId, userId, prompt, size });
if (result.images && result.images.length > 0) {
// 发送图片
const { default: axios } = await import('axios');
const fs = require('fs');
const path = require('path');
const os = require('os');
const sentUrls = [];
for (const img of result.images) {
// 优先用 base64 内联数据(无需访问被墙图床域名),无 base64 才尝试下载 url
if (img.b64_json) {
// base64 图片直接存文件发送
try {
const tmpDir = path.join(os.tmpdir(), 'ngbot_img');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
const fname = 'gen_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.png';
const fpath = path.join(tmpDir, fname);
fs.writeFileSync(fpath, Buffer.from(img.b64_json, 'base64'));
await ctx.sendMedia(fpath, 'image', fname);
try { fs.unlinkSync(fpath); } catch (_) {}
sentUrls.push('(base64 内联图片)');
} catch (e) {
console.error('[smart] 发送 base64 生成图片失败:', e.message);
}
} else if (img.url) {
try {
const tmpDir = path.join(os.tmpdir(), 'ngbot_img');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
const fname = 'gen_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.png';
const fpath = path.join(tmpDir, fname);
const res = await axios({ method: 'GET', url: img.url, responseType: 'arraybuffer', timeout: 60000 });
fs.writeFileSync(fpath, Buffer.from(res.data));
await ctx.sendMedia(fpath, 'image', fname);
try { fs.unlinkSync(fpath); } catch (_) {}
sentUrls.push(img.url);
} catch (e) {
console.error('[smart] 下载/发送生成图片失败:', e.message);
}
}
}
const q = result.quota;
return `已为你生成图片!${sentUrls.length > 0 ? '\n' + sentUrls.join('\n') : ''}${q ? '\n剩余生图次数:' + (q.limit - q.used) + '/' + q.limit : ''}`;
}
return '图片生成失败,服务器未返回任何图片。';
} catch (e) {
return '图片生成失败:' + e.message;
}
}
// ==================== 噪声消息过滤 ====================
// 过滤无意义的随机字母、乱码、键盘乱按等,避免浪费 Token
function isNoiseMessage(text) {
const len = text.length;
// 1-2个字符:只过滤纯符号/纯数字(保留中文、英文、常用语气词)
if (len <= 2) {
// 中文单字/双字(嗯、好、哦、哈哈、呵呵 等)不拦截
if (/[\u4e00-\u9fff]/.test(text)) return false;
// 英文短词(hi, ok, no, yo 等)不拦截
if (/^[a-zA-Z]{1,2}$/.test(text)) return false;
// 纯数字/纯符号/纯表情 → 过滤
if (/^[\d\W_]+$/.test(text)) return true;
return false;
}
// 全是同一个字符重复(aaaa, 1111, ......)
if (new Set(text).size <= 1) return true;
// 纯数字无意义长串(超过5位且无任何分隔符/中文/字母)
if (/^\d{5,}$/.test(text)) return true;
// 高熵无意义字符串判定:
// - 纯 ASCII 字母/数字/符号,无空格、无中文、无标点分隔
// - 长度 >= 8 且 字符熵 > 0.7(即几乎所有字符都不一样)
const asciiOnly = /^[\x00-\x7F]+$/.test(text);
if (asciiOnly && len >= 8 && !/[ .,!?;:,。!?;:\-\n]/.test(text)) {
const uniqueRatio = new Set(text).size / len;
// 高唯一比 + 无空格/标点 → 大概率是随机乱码/base64/哈希之类
if (uniqueRatio > 0.7) return true;
}
// 键盘乱按模式:交替的相邻键盘字母(如 asdfghjk, qwertyui)
if (asciiOnly && len >= 5 && isKeyboardMashing(text)) return true;
return false;
}
// 检测键盘横向/纵向乱按模式
function isKeyboardMashing(text) {
const keyboardRows = [
'qwertyuiop',
'asdfghjkl',
'zxcvbnm',
];
const lower = text.toLowerCase();
// 逐行检查是否大部分字符集中在同一行
for (const row of keyboardRows) {
let inRow = 0;
for (const ch of lower) {
if (row.includes(ch)) inRow++;
}
// 超过70%的字符都在同一行键盘上 → 乱按
if (inRow / lower.length > 0.7) return true;
}
// 检查是否有连续4个以上键盘相邻字符
for (let i = 0; i < lower.length - 3; i++) {
const sub = lower.substring(i, i + 4);
let adjacent = true;
for (let j = 1; j < sub.length; j++) {
let found = false;
for (const row of keyboardRows) {
const idx = row.indexOf(sub[j - 1]);
if (idx >= 0 && (row[idx + 1] === sub[j] || row[idx - 1] === sub[j])) {
found = true;
break;
}
}
if (!found) { adjacent = false; break; }
}
if (adjacent) return true;
}
return false;
}
// ==================== 默认系统提示词 ====================
const DEFAULT_SYSTEM_PROMPT = `你是一个智能助手,用户无需任何指令前缀,直接发送自然语言即可与你对话。
你具备以下能力:
- 识别用户发来的图片(认花、认物、认动物、看文字截图等)——这是自动完成的
- 识别用户发来的语音消息(微信语音会自动转成文字后交给你理解并回复)——这是自动完成的,你只需像处理普通文字一样回应
- 把用户最近发送的图片保存到 IMA 知识库(仅当用户明确说"把图片存到知识库""保存这张图"时,调用 save_image_to_knowledge;用户只发图片没说保存时绝不调用)
- 搜索用户的 IMA 知识库,查找文档、笔记、会议纪要、合同、供应商资料等
- 将文本/内容直接写入 IMA 知识库(用户说"存到知识库""记到知识库""写入知识库"时,调用 write_knowledge)
- 搜索用户的 IMA 笔记(用户说"我记过XX吗""找一下那篇笔记"时,调用 search_note)
- 读取某篇 IMA 笔记的完整内容(用户说"读一下那篇笔记""给我看全文"并提供笔记ID时,调用 read_note)
- 在 IMA 知识库中创建笔记(用户说"记录一下""记个笔记""保存到知识库"时)
- 将网页链接/公众号文章保存到 IMA 知识库(用户发链接并说"保存""收藏"时)
读取笔记的流程:当用户想读某篇笔记但没给 ID 时,先调用 search_note 用关键词找到笔记并拿到 id,再调用 read_note 读取全文。
- 设置定时提醒(支持相对时间、绝对时间、每天/每周重复)
- 查看和删除提醒
- 获取每日新闻简报
- 发送随机美图
- 日常对话
当用户的消息需要执行上述操作时,请调用对应的工具函数。获得工具返回结果后,用自然、友好的语言转述给用户。
重要规则:
- 如果用户没有明确说要执行某个操作,就当作普通对话处理,不要强行调用工具。
- 搜索知识库时,从用户的问题中提取最关键的搜索词。
- 设置提醒时,将用户的时间表述转换为标准格式(如"5分钟后"、"明天上午9点"、"每天8点"等)。
- 如果用户的消息语义不明确或像是随便发的无意义内容,可以简单回复一句引导用户说明需求,不要长篇大论。
始终保持简洁、有帮助、温暖的态度。回复不要太长。`;
// ==================== AI 人格预设 ====================
const PERSONAS = {
'': '',
'warm': '\n\n【说话风格】温柔体贴,像一位知心朋友。多用"呢""呀""哦"等语气词,回复中适当表达关心和共情。让人感到温暖和被理解。',
'cool': '\n\n【说话风格】简洁干练,像一位高冷但靠谱的技术大佬。少说废话,一针见血,偶尔带点冷幽默。不废话不啰嗦。',
'humorous': '\n\n【说话风格】幽默风趣,像一位段子手朋友。适当使用俏皮话、谐音梗和轻松的调侃。让人在对话中感到开心和放松。',
'otaku': '\n\n【说话风格】二次元萌系,像一位元气满满的动漫伙伴。可以偶尔使用"喵""捏""ww""诶嘿"等口癖,语气活泼可爱。',
'professional': '\n\n【说话风格】专业严谨,像一位资深顾问。表达条理清晰、有逻辑、有依据,但保持友好和耐心。像在和客户沟通。',
'cute': '\n\n【说话风格】软萌可爱,像一只暖心小萌宠。说话带叠词和语气词,让人感到治愈。比如"好滴好滴~""知道啦~"这样的感觉。',
'tsundere': '\n\n【说话风格】傲娇系,嘴上不饶人但内心很关心对方。表面嫌弃但实际很热心。比如"才...才不是因为关心你才帮你的呢!"',
};
function getPersonaText(persona) {
if (!persona) return '';
if (PERSONAS[persona]) return PERSONAS[persona];
return '\n\n' + persona;
}
// ==================== AI 调用 ====================
const modelStats = require('../../lib/model-stats');
async function callAI(cfg, messages, tools, meta) {
const t0 = Date.now();
const body = {
model: cfg.model,
messages,
max_tokens: cfg.max_tokens,
temperature: cfg.temperature,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
}
try {
const resp = await axios.post(cfg.api_base + '/v1/chat/completions', body, {
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.api_key },
timeout: 60000,
});
const u = resp.data && resp.data.usage;
const latency = Date.now() - t0;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: cfg.model,
mode: 'chat',
prompt_tokens: (u && u.prompt_tokens) || 0,
completion_tokens: (u && u.completion_tokens) || 0,
total_tokens: (u && u.total_tokens) || 0,
ttft_ms: latency,
latency_ms: latency,
success: 1,
});
} catch (_) {}
return resp.data;
} catch (e) {
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: cfg.model,
mode: 'chat',
success: 0,
error_msg: (e && e.message) || 'unknown',
latency_ms: Date.now() - t0,
});
} catch (_) {}
throw e;
}
}
// ==================== 多模型轮询(负载均衡 + 故障转移) ====================
// 轮询游标:每次调用取 (cursor % n) 并自增,使多个自定义模型被均摊请求。
let modelCursor = 0;
function nextModelIndex(n) {
if (n <= 1) return 0;
const i = modelCursor % n;
modelCursor = (modelCursor + 1) % n;
return i;
}
/**
* 带轮询 + 故障转移的 AI 调用。
* - cfg 可以是单模型({api_base,api_key,model})或自定义多模型({models:[...]})
* - 先按轮询游标选一个模型;若该模型报错或返回空,按顺序尝试其余模型(故障转移)
* @returns {Promise<object>} OpenAI chat completion 响应
*/
async function callAIWithFallback(cfg, messages, tools, meta) {
const models = cfg.models && cfg.models.length
? cfg.models
: [{ api_base: cfg.api_base, api_key: cfg.api_key, model: cfg.model }];
if (!models.length || !models[0].api_base || !models[0].api_key) {
throw new Error('AI 未配置(无可用模型)');
}
const strategy = cfg.endpoint_strategy || 'round_robin';
let start;
if (strategy === 'random') {
start = Math.floor(Math.random() * models.length);
} else if (strategy === 'fallback') {
start = 0; // 固定优先第一个,失败才顺序尝试其余(故障转移)
} else { // round_robin
start = nextModelIndex(models.length);
}
let lastErr = null;
for (let k = 0; k < models.length; k++) {
const idx = (start + k) % models.length;
try {
const r = await callAI(models[idx], messages, tools, meta);
if (r && r.choices && r.choices[0] && r.choices[0].message) return r;
lastErr = new Error('AI 返回空响应');
} catch (e) {
lastErr = e;
console.error(`[smart] 模型「${models[idx].model}」调用失败,轮询下一个:`, e.message);
}
}
throw lastErr || new Error('所有可用模型均调用失败');
}
// ==================== 图片识别(视觉模型) ====================
/**
* 调用「视觉模型」识别图片内容(OpenAI 兼容的多模态 chat/completions)。
* 消息 content 用数组形式携带 image_url(base64 data URL),非视觉模型会报错,由上层兜底。
* @param {object} cfg resolveSmartConfig 结果(单模型或多模型)
* @param {string} dataUrl data:image/...;base64,... 形式的图片
* @param {string} promptText 引导提示词
* @returns {Promise<{content:string, usage:number}>}
*/
async function callVision(cfg, dataUrl, promptText, meta) {
const models = cfg.models && cfg.models.length
? cfg.models
: [{ api_base: cfg.api_base, api_key: cfg.api_key, model: cfg.model }];
if (!models.length || !models[0].api_base || !models[0].api_key) {
throw new Error('AI 未配置(无可用模型)');
}
const messages = [{
role: 'user',
content: [
{ type: 'text', text: promptText },
{ type: 'image_url', image_url: { url: dataUrl } },
],
}];
let lastErr = null;
for (const m of models) {
const t0 = Date.now();
try {
const body = {
model: m.model,
messages,
max_tokens: cfg.max_tokens || 2000,
temperature: cfg.temperature != null ? cfg.temperature : 0.7,
};
const resp = await axios.post(m.api_base + '/v1/chat/completions', body, {
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.api_key },
timeout: 120000,
});
const content = resp.data?.choices?.[0]?.message?.content;
if (content && String(content).trim()) {
const u = resp.data?.usage;
const latency = Date.now() - t0;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: m.model,
mode: 'vision',
prompt_tokens: (u && u.prompt_tokens) || 0,
completion_tokens: (u && u.completion_tokens) || 0,
total_tokens: (u && u.total_tokens) || 0,
ttft_ms: latency,
latency_ms: latency,
success: 1,
});
} catch (_) {}
return { content: String(content).trim(), usage: u?.total_tokens || 0 };
}
lastErr = new Error('视觉模型返回空响应');
} catch (e) {
lastErr = e;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: m.model,
mode: 'vision',
success: 0,
error_msg: (e && e.message) || 'unknown',
latency_ms: Date.now() - t0,
});
} catch (_) {}
console.error(`[smart] 视觉模型「${m.model}」识别失败:`, e.response?.data?.error?.message || e.message);
}
}
throw lastErr || new Error('图片识别失败');
}
const IMAGE_RECOGNIZE_PROMPT = `请识别这张图片的内容并用中文简洁友好地描述:
- 如果是花卉/植物/动物/食物/物品,请说明它的名称(尽量给出具体品种),以及关键特征。
- 如果是文字/截图/文档,请概括其主要内容。
- 如果是风景/场景,请描述地点或场景类型。
控制在 150 字以内,不要啰嗦。`;
/**
* 处理入站图片消息:下载解密 → 视觉模型识别 → 回复识别结果。
* 注意:不会自动存知识库;如需保存,用户需明确说「存到知识库」(走 save_image_to_knowledge 工具)。
* @returns {Promise<boolean>} 是否已处理
*/
async function handleImageMessage(msg, ctx) {
const botId = ctx.bot.id;
const userId = ctx.bot.user_id;
const cfg = await resolveSmartConfig(botId);
if (!cfgHasAI(cfg)) return false; // AI 未配置,放行给其他插件
const tokenInfo = await getUserTokenInfo(userId);
if (tokenInfo.used >= tokenInfo.limit) return false; // 额度用尽,放行
// 下载并解密用户发来的图片(复用 ima-knowledge 的 CDN 下载 + AES 解密)
let img;
try {
img = await imaPlugin.downloadInboundImage(ctx.bot, msg.content);
} catch (e) {
console.error('[smart] 图片下载失败:', e.message);
return false;
}
if (!img || !img.buffer || !img.buffer.length) return false;
const mimeMap = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
bmp: 'image/bmp', webp: 'image/webp',
};
const mime = mimeMap[(img.ext || 'jpg').toLowerCase()] || 'image/jpeg';
const dataUrl = 'data:' + mime + ';base64,' + img.buffer.toString('base64');
try {
const { content, usage } = await callVision(cfg, dataUrl, IMAGE_RECOGNIZE_PROMPT, { bot_id: botId, peer_id: msg.peer_id });
if (usage > 0) await addUserTokens(userId, usage);
// 若已配置 IMA 知识库,提示用户可存图(图片不再自动存)
let hint = '';
try {
const imaCfg = await loadImaConfig(botId);
if (imaCfg.client_id && imaCfg.api_key) {
hint = '\n\n[Tip] 如需保存这张图片到知识库,请回复「把图片存到知识库」。';
}
} catch (_) { /* ignore */ }
await ctx.sendText('[Ref] 图片识别结果:\n' + content + hint);
return true;
} catch (e) {
console.error('[smart] 图片识别失败:', e.message);
await ctx.sendText('[WARN] 图片识别失败:当前 AI 模型可能不支持图片识别,请联系管理员配置支持视觉的模型(如 gpt-4o、gpt-4o-mini、qwen-vl 等)。');
return true;
}
}
// ==================== 语音识别(SILK 解码 + Whisper 转写) ====================
/** PCM(s16le) → WAV 封装(Whisper 可识别) */
function pcmToWav(pcm, sampleRate, channels, bitsPerSample) {
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcm.length;
const buf = Buffer.alloc(44 + dataSize);
buf.write('RIFF', 0);
buf.writeUInt32LE(36 + dataSize, 4);
buf.write('WAVE', 8);
buf.write('fmt ', 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(channels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(byteRate, 28);
buf.writeUInt16LE(blockAlign, 32);
buf.writeUInt16LE(bitsPerSample, 34);
buf.write('data', 36);
buf.writeUInt32LE(dataSize, 40);
pcm.copy(buf, 44);
return buf;
}
/**
* 把微信入站语音(解密后的原始字节)转成 Whisper 可识别的音频。
* - 已是 WAV → 直接返回
* - SILK 编码(微信语音常见)→ 解码为 PCM 再封装 WAV
* - 其它(AMR 等)→ 原样返回,交给识别接口尝试
* @returns {{buffer:Buffer, ext:string, mime:string}}
*/
async function voiceToWav(buffer, ref) {
if (silk.isWav(buffer)) return { buffer, ext: 'wav', mime: 'audio/wav' };
if (silk.isSilk(buffer)) {
const sampleRate = (ref && ref.sample_rate) || 24000;
const { data: pcm } = await silk.decode(buffer, sampleRate);
if (!pcm || !pcm.length) throw new Error('SILK 解码结果为空');
return { buffer: pcmToWav(Buffer.from(pcm), sampleRate, 1, 16), ext: 'wav', mime: 'audio/wav' };
}
return { buffer, ext: 'amr', mime: 'audio/amr' };
}
/**
* 调用 OpenAI 兼容的语音识别接口 /v1/audio/transcriptions。
* 模型取 cfg.transcribe_model(默认 whisper-1);多模型只取第一个。
* @returns {Promise<string>} 识别出的文本
*/
async function callTranscribe(cfg, audio, meta) {
const list = cfg.models && cfg.models.length ? cfg.models : [cfg];
const m = list[0];
if (!m || !m.api_base || !m.api_key) throw new Error('AI 未配置');
const transModel = (m.transcribe_model) || cfg.transcribe_model || 'whisper-1';
const base = normalizeBase(m.api_base);
const url = base + '/v1/audio/transcriptions';
const t0 = Date.now();
const blob = new Blob([audio.buffer], { type: audio.mime || 'audio/wav' });
const form = new FormData();
form.append('file', blob, 'voice.' + (audio.ext || 'wav'));
form.append('model', transModel);
form.append('response_format', 'json');
try {
const resp = await axios.post(url, form, {
headers: { Authorization: 'Bearer ' + m.api_key },
timeout: 120000,
});
const text = resp.data?.text || resp.data?.transcript || '';
const latency = Date.now() - t0;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: transModel,
mode: 'transcribe',
success: 1,
latency_ms: latency,
});
} catch (_) {}
return String(text).trim();
} catch (e) {
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model: transModel,
mode: 'transcribe',
success: 0,
error_msg: (e && e.message) || 'unknown',
latency_ms: Date.now() - t0,
});
} catch (_) {}
throw e;
}
}
/**
* 调用 MIMO 语音识别接口(api.xiaomimimo.com)。
* 走 /v1/chat/completions,模型 mimo-v2.5-asr,音频以 input_audio(base64 data URL) 形式提交。
* 详见:https://api.xiaomimimo.com/v1/chat/completions
* @returns {Promise<string>} 识别出的文本
*/
async function callMimoTranscribe(cfg, audio, meta) {
const apiKey = cfg.stt_mimo_api_key || '';
const base = (cfg.stt_mimo_base || 'https://api.xiaomimimo.com').replace(/\/+$/, '');
const model = cfg.stt_mimo_model || 'mimo-v2.5-asr';
const lang = cfg.stt_mimo_lang || 'zh';
if (!apiKey) throw new Error('MIMO API Key 未配置(后台「语音识别模式」选 MIMO 时需填写)');
const b64 = audio.buffer.toString('base64');
const mime = audio.mime || 'audio/wav';
const url = base + '/v1/chat/completions';
const t0 = Date.now();
let resp;
try {
resp = await axios.post(url, {
model,
messages: [
{
role: 'user',
content: [
{ type: 'input_audio', input_audio: { data: `data:${mime};base64,${b64}` } },
],
},
],
asr_options: { language: lang },
}, { headers: { 'api-key': apiKey, 'Content-Type': 'application/json' }, timeout: 60000 });
} catch (e) {
const detail = e.response && e.response.data ? JSON.stringify(e.response.data) : e.message;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model,
mode: 'transcribe',
success: 0,
error_msg: 'MIMO 请求失败:' + detail,
latency_ms: Date.now() - t0,
});
} catch (_) {}
throw new Error('MIMO 请求失败:' + detail);
}
const content = (resp && resp.data && resp.data.choices && resp.data.choices[0] && resp.data.choices[0].message && resp.data.choices[0].message.content)
|| (resp && resp.data && resp.data.choices && resp.data.choices[0] && resp.data.choices[0].text)
|| (resp && resp.data && resp.data.text)
|| '';
const latency = Date.now() - t0;
try {
modelStats.logModelCall({
bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
peer_id: meta && meta.peer_id || null,
model,
mode: 'transcribe',
success: 1,
latency_ms: latency,
});
} catch (_) {}
return String(content).trim();
}
/**
* 处理入站语音消息:下载解密 → SILK 解码 → 语音识别 → 像普通文本一样交给 AI 理解并回复。
* @returns {Promise<boolean>} 是否已处理
*/
async function handleVoiceMessage(msg, ctx) {
const botId = ctx.bot.id;
const userId = ctx.bot.user_id;
const peerId = msg.peer_id;
const cfg = await resolveSmartConfig(botId);
if (!cfgHasAI(cfg)) return false; // AI 未配置,放行给其他插件
const tokenInfo = await getUserTokenInfo(userId);
if (tokenInfo.used >= tokenInfo.limit) return false; // 额度用尽,放行
// 下载并解密用户发来的语音(复用 ima-knowledge 的 CDN 下载 + AES 解密)
let media;
try {
media = await imaPlugin.downloadInboundMedia(ctx.bot, msg.content);
} catch (e) {
console.error('[smart] 语音下载失败:', e.message);
return false;
}
if (!media || !media.buffer || !media.buffer.length) return false;
// 按「语音识别模式」分流:local=本地 Whisper(纯本地、无外部接口);ai=OpenAI 兼容接口
const sttMode = (cfg.stt_mode || 'ai');
let text;
if (sttMode === 'local') {
// —— 本地 Whisper 转写 ——
let float32;
try {
float32 = await stt.decodeVoiceToFloat(media.buffer, media.ref);
} catch (e) {
console.error('[smart] 语音解码失败:', e.message);
await ctx.sendText('[WARN] 语音解码失败:' + e.message);
return true;
}
try {
text = await stt.transcribeLocal(float32, { model: cfg.stt_model, language: 'chinese' });
} catch (e) {
console.error('[smart] 本地语音识别失败:', e.message);
await ctx.sendText('[WARN] 本地语音识别失败:' + e.message +
'\n(首次使用会自动下载模型,请确认服务器能访问 HuggingFace;国内可设置环境变量 HF_ENDPOINT=https://hf-mirror.com。' +
'或后台把「语音识别模式」改回 AI 接口)');
return true;
}
} else if (sttMode === 'ai') {
// —— AI 接口(OpenAI 兼容 /v1/audio/transcriptions)——
let audio;
try {
audio = await voiceToWav(media.buffer, media.ref);
} catch (e) {
console.error('[smart] 语音解码失败:', e.message);
await ctx.sendText('[WARN] 语音解码失败:' + e.message);
return true;
}
try {
text = await callTranscribe(cfg, audio, { bot_id: botId, peer_id: peerId });
} catch (e) {
console.error('[smart] 语音识别失败:', e.message);
await ctx.sendText('[WARN] 语音识别失败:' + e.message + '\n(请确认 AI 服务商支持 /v1/audio/transcriptions 语音识别,或在后台设置「语音识别模型」)');
return true;
}
} else if (sttMode === 'mimo') {
// —— MIMO 语音识别(api.xiaomimimo.com /v1/chat/completions,mimo-v2.5-asr)——
let audio;
try {
audio = await voiceToWav(media.buffer, media.ref);
} catch (e) {
console.error('[smart] 语音解码失败:', e.message);
await ctx.sendText('[WARN] 语音解码失败:' + e.message);
return true;
}
try {
text = await callMimoTranscribe(cfg, audio, { bot_id: botId, peer_id: peerId });
} catch (e) {
console.error('[smart] MIMO 语音识别失败:', e.message);
await ctx.sendText('[WARN] MIMO 语音识别失败:' + e.message + '\n(请确认后台已填写 MIMO API Key,且服务商支持语音识别)');
return true;
}
}
if (!text) {
await ctx.sendText('[WARN] 未识别到语音内容,请重试。');
return true;
}
console.log('[smart] 语音识别结果:', JSON.stringify(text));
// 回写识别结果到入库的语音消息:仅填充 t 字段(保留媒体引用 r),
// 使网页聊天界面展示「语音转换后的文字」而非原始 JSON。
if (msg.message_id && text) {
try {
let parsed;
try { parsed = JSON.parse(msg.content); } catch (_) { parsed = null; }
if (parsed && typeof parsed === 'object') {
parsed.t = text;
const newContent = JSON.stringify(parsed);
await db.exec('UPDATE messages SET content=? WHERE id=?', [newContent, msg.message_id]);
}
} catch (e) {
console.error('[smart] 语音消息回写失败:', e.message);
}
}
// 把识别到的文字直接交给智能助手(像用户自己打字一样理解并回复),不额外回显提示
return await processText(botId, peerId, userId, text, ctx, msg, true);
}
// ==================== 插件入口 ====================
/**
* 供第三方插件直接调用智能助手(AI 文本生成 / function calling)。
* 无需指令前缀、无需走聊天管线,插件可在自己的业务逻辑里随时调用 AI。
*
* @param {object} opts
* botId:number 机器人 ID(必填,决定用哪种模型配置)
* userId?:number 用户 ID(用于 Token 额度扣减;不传则不扣额度)
* prompt:string 要发给 AI 的内容(必填)
* systemPrompt?:string 覆盖默认系统提示词
* maxTokens?:number 覆盖 max_tokens
* temperature?:number 覆盖 temperature
* history?:[{role,content}] 多轮上下文(可选,置于 prompt 之前)
* tools?:[...] OpenAI function 工具定义(可选)
* toolHandler?:async (name, args) => string 配合 tools 的工具执行器(可选)
* @returns {Promise<string>} AI 回复文本
*
* 示例:
* const smart = require('../../plugins/smart');
* const reply = await smart.generate({ botId, userId, prompt: '用一句话解释量子纠缠' });
*/
async function generate(opts) {
const { botId, userId, prompt } = opts || {};
if (!botId) throw new Error('generate 缺少 botId');
if (!prompt || !String(prompt).trim()) throw new Error('generate 缺少 prompt');
const cfg = await resolveSmartConfig(botId);
if (!cfgHasAI(cfg)) throw new Error('智能助手 AI 未配置');
// Token 额度检查(与内置对话一致)
if (userId) {
const tokenInfo = await getUserTokenInfo(userId);
if (tokenInfo.used >= tokenInfo.limit) throw new Error('AI Token 额度已用尽');
}
const systemPrompt = (opts.systemPrompt || cfg.system_prompt || DEFAULT_SYSTEM_PROMPT)
+ getPersonaText(cfg.persona || '')
+ await collectPluginPrompts(botId, opts && opts.peerId);
const messages = [
{ role: 'system', content: systemPrompt },
...(opts.history && Array.isArray(opts.history) ? opts.history : []),
{ role: 'user', content: String(prompt) },
];
let usageTokens = 0;
let resp;
try {
if (opts.tools && opts.tools.length) {
resp = await callAIWithFallback(cfg, messages, opts.tools, { bot_id: botId, peer_id: opts.peerId });
let loops = 0;
while (resp.choices?.[0]?.message?.tool_calls?.length > 0 && loops < MAX_TOOL_LOOPS) {
loops++;
const tcs = resp.choices[0].message.tool_calls;
messages.push({ role: 'assistant', content: resp.choices[0].message.content || null, tool_calls: tcs });
for (const tc of tcs) {
const fn = tc.function?.name;
let args = {};
try { args = JSON.parse(tc.function?.arguments || '{}'); } catch (_) {}
let result = '';
if (typeof opts.toolHandler === 'function') {
try { result = String(await opts.toolHandler(fn, args) ?? ''); }
catch (e) { result = '工具执行失败: ' + e.message; }
}
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
}
resp = await callAIWithFallback(cfg, messages, null);
usageTokens += resp.usage?.total_tokens || 0;
}
} else {
resp = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: opts.peerId });
}
usageTokens += resp.usage?.total_tokens || 0;
} catch (e) {
throw new Error('AI 调用失败: ' + e.message);
}
// 扣减 Token
if (userId && usageTokens > 0) await addUserTokens(userId, usageTokens);
return resp.choices?.[0]?.message?.content || '';
}
/** 返回当前机器人的模型模式(供前端/调试用) */
async function getAiMode(botId) {
const cfg = await resolveSmartConfig(botId);
return {
source: cfg.source, // 'owner' | 'custom'
customModels: cfg.models ? cfg.models.length : 0,
};
}
module.exports = {
meta: {
id: 'smart',
name: '智能助手',
version: '1.3.0',
author: '奶狗',
category: 'AI对话',
description: '自然语言智能助手,无需指令前缀。AI 自动理解意图,支持图片识别(认花/认物/识文字)、微信语音自动转文字并回复、知识库搜索、定时提醒、新闻简报、随机图片。图片默认只识别不入库,用户明确说"存到知识库"才保存;语音消息会自动识别成文字后由 AI 理解回复。AI 接口在「AI 配置」菜单统一设置。第三方插件也可直接调用本助手进行 AI 生成。',
entry: 'smart/index.js',
builtin: true,
},
// 供第三方插件调用
generate,
getAiMode,
generateImage: callImageGeneration,
handleGenerateImageTool,
async onMessage(msg, ctx) {
// 确保 reminders 表存在(只建一次,移出消息热路径)
await ensureRemindersTable();
// 启动提醒调度器(幂等)
startReminderScheduler();
const botId = ctx.bot.id;
const userId = ctx.bot.user_id;
const peerId = msg.peer_id;
const text = (msg.content || '').trim();
// 图片消息:用视觉模型自动识别内容(认花/物体/文字等),不自动存知识库
if (msg.msg_type === 'image') {
return await handleImageMessage(msg, ctx);
}
// 语音消息:下载解密 → SILK 解码 → 语音识别 → 像文本一样交给 AI 回复
if (msg.msg_type === 'voice') {
return await handleVoiceMessage(msg, ctx);
}
if (!text) return false;
return await processText(botId, peerId, userId, text, ctx, msg, false);
},
};
/**
* 统一的文本处理管线:噪声过滤 → 指令让位 → 加载模型 → 对话历史 → function calling → 回复。
* 普通文本消息与「语音识别后的文本」都走这里,保证语音消息也能被 AI 正常理解并回复。
* @param {boolean} isVoice 是否来自语音识别(用于后续扩展,如跳过噪声过滤等)
* @returns {Promise<boolean>} 是否已处理
*/
async function processText(botId, peerId, userId, text, ctx, msg, isVoice) {
if (!text) return false;
console.log('[smart] processText 进入: isVoice=' + isVoice + ', text=' + JSON.stringify(text.slice(0, 80)));
// 过滤无意义噪声消息(随机字母、乱码等),节省 Token
if (isNoiseMessage(text)) { console.log('[smart] 跳过:噪声消息'); return false; }
// 让位给「指令型插件」:若消息以某插件的命令前缀开头,则不走 AI,交给对应插件处理
// (例如 push 插件的「推送 绑定」等指令,不应被智能助手当成闲聊回复)
try {
const pluginsLib = require('../../lib/plugins');
const others = (await pluginsLib.getEnabledModules(botId)).filter(m => m.meta && m.meta.id !== 'smart');
const lower = text.toLowerCase();
for (const m of others) {
const prefixes = m.meta.commandPrefix;
if (prefixes) {
const arr = Array.isArray(prefixes) ? prefixes : [prefixes];
if (arr.some(p => p && lower.startsWith(String(p).toLowerCase()))) {
console.log('[smart] 跳过:命中插件指令前缀', m.meta.id, JSON.stringify(p));
return false;
}
}
// 柔性匹配:插件可声明 commandMatch 正则,命中则同样让位给该插件
// (用于处理自然语言指令,如「看一下航天基地密码」)
if (m.meta.commandMatch) {
try {
if (new RegExp(m.meta.commandMatch, 'i').test(text)) {
console.log('[smart] 跳过:命中插件命令正则', m.meta.id, m.meta.commandMatch);
return false;
}
} catch (_) { /* 正则无效则忽略 */ }
}
}
} catch (_) { /* 忽略,继续走 AI */ }
// 兑换卡密指令 → 交给 token-card 插件处理,不走 AI
if (/^兑换\s+[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/i.test(text)) return false;
// 加载 AI 接口配置(自定义模型或多模型轮询;否则使用站长模型)
const cfg = await resolveSmartConfig(botId);
if (!cfgHasAI(cfg)) {
// AI 未配置,不接管消息,让其他插件处理
console.log('[smart] 跳过:AI 未配置');
return false;
}
// 检查 Token 额度
const tokenInfo = await getUserTokenInfo(userId);
if (tokenInfo.used >= tokenInfo.limit) {
// Token 用完了,不走 AI,让其他插件处理
console.log('[smart] 跳过:Token 额度用尽', JSON.stringify(tokenInfo));
return false;
}
// 先发「请稍候」:在确定要走 AI 后、构建系统提示与收集插件工具之前就告知用户,
// 避免配置文件/插件 prompt/tool 收集(可能遍历多个插件)的耗时让用户长时间无反馈。
try { await ctx.sendText('⏳ 请稍候…'); }
catch (_) { /* 发送失败不影响主流程 */ }
// 添加用户消息到对话历史
addConvMessage(botId, peerId, 'user', text);
// 构建消息列表
const history = getConvHistory(botId, peerId);
const systemPrompt = (cfg.system_prompt || DEFAULT_SYSTEM_PROMPT)
+ getPersonaText(cfg.persona || '')
+ await collectPluginPrompts(botId, peerId);
const messages = [
{ role: 'system', content: systemPrompt },
...history,
];
// 收集第三方插件声明的 AI 工具,与内置工具合并(通用互通协议)
const { tools: pluginTools, handlers: pluginHandlers } = await collectPluginTools(botId);
const allTools = pluginTools.length ? [...TOOLS, ...pluginTools] : TOOLS;
try {
console.log('[smart] 开始调用 AI(tools=' + allTools.length + ')');
let resp = await callAIWithFallback(cfg, messages, allTools, { bot_id: botId, peer_id: peerId });
console.log('[smart] AI 返回成功,正文长度=' + (resp.choices?.[0]?.message?.content || '').length);
let usageTokens = resp.usage?.total_tokens || 0;
let loops = 0;
const toolResults = [];
// Function calling 循环
while (resp.choices?.[0]?.message?.tool_calls?.length > 0 && loops < MAX_TOOL_LOOPS) {
loops++;
const toolCalls = resp.choices[0].message.tool_calls;
// 添加 assistant 消息(含 tool_calls)
messages.push({
role: 'assistant',
content: resp.choices[0].message.content || null,
tool_calls: toolCalls,
});
// 执行每个工具
for (const tc of toolCalls) {
const fnName = tc.function?.name;
let fnArgs = {};
try { fnArgs = JSON.parse(tc.function?.arguments || '{}'); } catch (e) { /* ignore */ }
console.log('[smart] 工具调用:', fnName, JSON.stringify(fnArgs).slice(0, 200));
// 单工具失败不应中断整体流程:捕获后把错误作为工具结果回填
try {
const result = await executeTool(fnName, fnArgs, ctx, pluginHandlers);
toolResults.push(String(result || ''));
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: String(result),
});
} catch (e) {
const errMsg = '工具执行失败: ' + e.message;
console.error('[smart] 工具执行异常 (' + fnName + '):', e.message);
toolResults.push(errMsg);
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: errMsg,
});
}
}
// 继续对话
resp = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: peerId });
usageTokens += resp.usage?.total_tokens || 0;
}
// 累计 Token
if (usageTokens > 0) {
await addUserTokens(userId, usageTokens);
}
// 获取最终回复
let reply = resp.choices?.[0]?.message?.content;
// 兜底:若最终只有 tool_calls 而无正文(部分模型会这样)
if (!reply || !reply.trim()) {
// 优先使用本轮工具执行结果:任务已执行(如已创建提醒/已发邮件),至少把结果回执给用户
const toolText = toolResults.filter(Boolean).join('\n').trim();
if (toolText) {
console.log('[smart] 末轮无正文,改用工具执行结果回复');
reply = toolText;
} else {
console.log('[smart] 末轮无正文,触发无工具兜底重试');
messages.push({ role: 'user', content: '请直接用一段文字回答上面的请求,不要调用任何工具。' });
try {
const r2 = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: peerId });
reply = r2.choices?.[0]?.message?.content;
usageTokens += r2.usage?.total_tokens || 0;
} catch (e) {
console.error('[smart] 兜底重试失败:', e.message);
}
}
}
if (reply && reply.trim()) {
// 注意:会话历史里只存纯净正文,避免「内容由 AI 生成」反复污染上下文;
// 「内容由 AI 生成」标注仅作为页脚附加在实际发送的消息末尾。
addConvMessage(botId, peerId, 'assistant', reply.trim());
console.log('[smart] 准备发送回复:', reply.trim().slice(0, 60));
try {
await ctx.sendText(reply.trim() + '\n\n— 内容由 AI 生成');
console.log('[smart] 回复已发送');
msgEvents.push(botId, 'outbound_ok', 'AI 回复已发送', reply.trim().slice(0, 80));
} catch (e) {
console.error('[smart] 回复发送失败:', e.message);
msgEvents.push(botId, 'outbound_fail', 'AI 回复发送失败', e.message);
throw e;
}
return true;
}
// 无有效回复
console.log('[smart] AI 返回空回复(content 为空),未发送。tool_calls=', JSON.stringify(resp.choices?.[0]?.message?.tool_calls?.map(t => t.function?.name)));
msgEvents.push(botId, 'outbound_fail', 'AI 未返回可发送内容', '工具结果: ' + toolResults.filter(Boolean).join(' | ').slice(0, 120));
return false;
} catch (err) {
console.error('[smart] AI调用失败:', err.message);
// 向上抛出,由 plugins.onMessage 记录 error 事件并让其他插件尝试处理
throw err;
}
}