码桶
发现社区成员的开源项目
index.js37.5 KB
/**
* 内置插件:IMA 知识库 / 笔记
* --------------------------------------------------
* 通过 IMA OpenAPI 操作腾讯 IMA 的知识库与笔记。
*
* 配置项(通过 plugin_settings 表按 bot 级别存储):
* client_id - IMA OpenAPI Client ID
* api_key - IMA OpenAPI API Key
* trigger - 知识库触发前缀,默认 "知识库"
*
* 用法:
* 知识库 列表 → 列出可操作的知识库
* 知识库 搜索 关键词 → 搜索知识库内容(读)
* 知识库 导入 url1,url2 → 把网页链接加入知识库(写)
* 知识库 写入 标题 | 内容 → 把一段文本作为内容写入知识库(写)
*
* 笔记 列表 → 列出最近笔记(读)
* 笔记 搜索 关键词 → 搜索笔记(读)
* 笔记 读取 <笔记ID> → 读取某篇笔记全文(读)
* 笔记 创建 内容 → 新建一篇笔记(写)
*/
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const db = require('../../lib/db');
const config = require('../../config');
const IMA_BASE = 'https://ima.qq.com';
const IMA_API = IMA_BASE + '/openapi/wiki/v1';
const NOTE_API = IMA_BASE + '/openapi/note/v1';
/** 读取插件设置 */
async function loadSettings(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 || '',
trigger: cfg.trigger !== undefined ? cfg.trigger : '知识库',
};
}
/** 获取 IMA 请求头 */
function imaHeaders(cfg) {
return {
'Content-Type': 'application/json',
'ima-openapi-clientid': cfg.client_id,
'ima-openapi-apikey': cfg.api_key,
};
}
/** 检查触发词,返回提取后的命令文本;未命中返回 null */
function matchTrigger(text, trigger) {
const prefixes = [trigger + ' ', trigger + ',', '/' + trigger + ' '];
for (const p of prefixes) {
if (text.startsWith(p)) return text.slice(p.length).trim();
}
if (text === trigger || text === '/' + trigger) return '';
return null;
}
/* ==================== 知识库:读 ==================== */
/** 搜索知识库(按名称/关键词匹配知识库) */
async function searchKnowledgeBases(cfg, query) {
const resp = await axios.post(
IMA_API + '/search_knowledge_base',
{ query, cursor: '', limit: 10 },
{ headers: imaHeaders(cfg), timeout: 30000 }
);
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '搜索知识库失败');
return (data.data && data.data.info_list) || [];
}
/** 在指定知识库内搜索内容 */
async function searchInKnowledge(cfg, knowledgeBaseId, query) {
const resp = await axios.post(
IMA_API + '/search_knowledge',
{ query, cursor: '', knowledge_base_id: knowledgeBaseId },
{ headers: imaHeaders(cfg), timeout: 30000 }
);
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '搜索内容失败');
return (data.data && data.data.info_list) || [];
}
/** 获取可操作的知识库列表 */
async function getKnowledgeBaseList(cfg) {
const resp = await axios.post(
IMA_API + '/get_addable_knowledge_base_list',
{ cursor: '', limit: 50 },
{ headers: imaHeaders(cfg), timeout: 30000 }
);
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '获取知识库列表失败');
return (data.data && data.data.addable_knowledge_base_list) || [];
}
/* ==================== 知识库:写 ==================== */
/**
* 创建媒体并获取 COS 上传临时凭证
* 官方接口:/openapi/wiki/v1/create_media
* 返回 { media_id, cos_credential: { token, secret_id, secret_key, bucket_name, region, cos_key, ... } }
*/
async function createMedia(cfg, knowledgeBaseId, { file_name, file_size, content_type, file_ext }) {
const resp = await axios.post(
IMA_API + '/create_media',
{ knowledge_base_id: knowledgeBaseId, file_name, file_size, content_type, file_ext },
{ headers: imaHeaders(cfg), timeout: 30000 }
);
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '创建媒体失败');
const d = data.data || {};
if (!d.media_id || !d.cos_credential || !d.cos_credential.cos_key) {
throw new Error('创建媒体返回数据不完整');
}
return d;
}
/**
* 计算腾讯云 COS 上传所需的 Authorization 签名(临时密钥方式)
* 严格对齐官方 ima-skills 的 cos-upload.cjs:签名 host + content-length,
* 有效期使用 IMA 返回的 cos_credential.start_time / expired_time。
*/
function cosAuth(cred, method, cosKey, contentLength) {
const startTime = cred.start_time ? Number(cred.start_time) : Math.floor(Date.now() / 1000);
const expiredTime = cred.expired_time ? Number(cred.expired_time) : startTime + 3600;
const keyTime = startTime + ';' + expiredTime;
const host = cred.bucket_name + '.cos.' + cred.region + '.myqcloud.com';
const pathname = '/' + cosKey;
const signHeaders = {
'content-length': String(contentLength),
host: host,
};
const headerKeys = Object.keys(signHeaders).sort();
const httpHeaders = headerKeys
.map(k => k.toLowerCase() + '=' + encodeURIComponent(signHeaders[k]))
.join('&');
const httpString = method.toLowerCase() + '\n' + pathname + '\n\n' + httpHeaders + '\n';
const signKey = crypto.createHmac('sha1', cred.secret_key).update(keyTime).digest('hex');
const stringToSign =
'sha1\n' + keyTime + '\n' + crypto.createHash('sha1').update(httpString).digest('hex') + '\n';
const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex');
const headerList = headerKeys.map(k => k.toLowerCase()).join(';');
return [
'q-sign-algorithm=sha1',
'q-ak=' + cred.secret_id,
'q-sign-time=' + keyTime,
'q-key-time=' + keyTime,
'q-header-list=' + headerList,
'q-url-param-list=',
'q-signature=' + signature,
].join('&');
}
/** 将二进制 buffer 通过临时密钥上传到腾讯云 COS */
async function uploadToCos(cred, cosKey, buffer, contentType) {
const url = 'https://' + cred.bucket_name + '.cos.' + cred.region + '.myqcloud.com' + '/' + cosKey;
const auth = cosAuth(cred, 'PUT', cosKey, buffer.length);
const resp = await axios.put(url, buffer, {
headers: {
'Authorization': auth,
'x-cos-security-token': cred.token,
'Content-Type': contentType,
'Content-Length': buffer.length,
},
timeout: 60000,
responseType: 'text',
});
return resp.status >= 200 && resp.status < 300;
}
/**
* 将一段文本作为文件(TXT=13 / Markdown=7)写入知识库。
* 正确流程:create_media(拿 COS 凭证) → COS 上传 → add_knowledge。
* 不能直接 POST base64 / content,否则 media_type 不匹配。
*
* @param {object} cfg
* @param {string} knowledgeBaseId
* @param {string} title 文件标题
* @param {string} text 文本内容
* @param {object} [opts] { folderId, mediaType=13 }
*/
async function addKnowledgeText(cfg, knowledgeBaseId, title, text, opts = {}) {
const mediaType = opts.mediaType === 7 ? 7 : 13;
const ext = mediaType === 7 ? 'md' : 'txt';
const contentType = mediaType === 7 ? 'text/markdown' : 'text/plain';
const buf = Buffer.from(text, 'utf8');
const file_name = (title || 'knowledge') + '.' + ext;
const cm = await createMedia(cfg, knowledgeBaseId, {
file_name,
file_size: buf.length,
content_type: contentType,
file_ext: ext,
});
const cred = cm.cos_credential;
await uploadToCos(cred, cred.cos_key, buf, contentType);
const body = {
media_type: mediaType,
media_id: cm.media_id,
title: title || file_name,
knowledge_base_id: knowledgeBaseId,
file_info: {
cos_key: cred.cos_key,
file_size: buf.length,
file_name,
},
};
if (opts.folderId) body.folder_id = opts.folderId;
const resp = await axios.post(IMA_API + '/add_knowledge', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '添加到知识库失败');
return data.data || {};
}
/** 添加内容(文本/笔记)到知识库 */
async function addKnowledge(cfg, knowledgeBaseId, title, mediaType, extra) {
const body = { knowledge_base_id: knowledgeBaseId, media_type: mediaType, title };
if (extra) Object.assign(body, extra);
const resp = await axios.post(IMA_API + '/add_knowledge', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '添加到知识库失败');
return data.data || {};
}
/** 导入网页链接到知识库 */
async function importUrls(cfg, knowledgeBaseId, urls, folderId) {
const body = { knowledge_base_id: knowledgeBaseId, urls };
if (folderId) body.folder_id = folderId;
const resp = await axios.post(IMA_API + '/import_urls', body, { headers: imaHeaders(cfg), timeout: 60000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '导入链接失败');
return data.data || {};
}
/** 判断 URL 是否指向图片(按扩展名粗略判断) */
function isImageUrl(url) {
const u = String(url || '').split('?')[0].toLowerCase();
return /\.(jpg|jpeg|png|gif|bmp|webp|svg|heic)(\?|$)/.test(u);
}
/** 从网络 URL 下载图片,返回 { buffer, ext } */
async function downloadImageFromUrl(url) {
const resp = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
});
const buf = Buffer.from(resp.data);
const ct = (resp.headers && (resp.headers['content-type'] || resp.headers['Content-Type'])) || '';
const extByCt = {
'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/bmp': 'bmp', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/heic': 'heic',
};
let ext = (String(url).split('?')[0].split('.').pop() || '').toLowerCase().slice(0, 4);
if (extByCt[ct]) ext = extByCt[ct];
return { buffer: buf, ext: ext || 'jpg' };
}
/**
* 从入站消息的 content(JSON: {l,r,t})解析并下载/解密用户发来的媒体文件。
* 复用 routes/api.js 中 bot_media 的 CDN 下载 + AES-128-ECB 解密逻辑。
* @returns {{buffer:Buffer, ref:object}|null} ref 为原始媒体引用(含 sample_rate 等)
*/
async function fetchDecryptRef(bot, contentStr) {
if (!contentStr) return null;
let parsed;
try { parsed = JSON.parse(contentStr); } catch (e) { return null; }
const ref = parsed && parsed.r;
if (!ref || !ref.encrypt_query_param) return null;
const cdnBase = config.ilink_cdn || 'https://novac2c.cdn.weixin.qq.com/c2c';
const cdnUrl = cdnBase + '/download?encrypted_query_param=' + encodeURIComponent(ref.encrypt_query_param);
const resp = await axios.get(cdnUrl, { responseType: 'arraybuffer', timeout: 30000 });
// AES-128-ECB 解密(与发送时一致:aes_key = base64(hex字符串))
const aesKeyHex = Buffer.from(ref.aes_key, 'base64').toString('utf-8');
const aesKey = Buffer.from(aesKeyHex, 'hex');
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null);
decipher.setAutoPadding(true);
const decrypted = Buffer.concat([decipher.update(Buffer.from(resp.data)), decipher.final()]);
return { buffer: decrypted, ref };
}
/**
* 从入站消息的 content(JSON: {l,r,t})解析并下载/解密用户发来的图片。
* 复用 routes/api.js 中 bot_media 的 CDN 下载 + AES-128-ECB 解密逻辑。
* 返回 { buffer, ext },无法解析时返回 null。
*/
async function downloadInboundImage(bot, contentStr) {
const r = await fetchDecryptRef(bot, contentStr);
if (!r) return null;
return { buffer: r.buffer, ext: 'jpg' };
}
/**
* 从入站消息的 content 解析并下载/解密用户发来的任意媒体(图片/语音/视频/文件)。
* 与 downloadInboundImage 的区别:返回原始 buffer + 完整 ref(含 sample_rate 等元数据),
* 供智能助手对语音做 SILK 解码 / 识别时使用。
* @returns {{buffer:Buffer, ref:object}|null}
*/
async function downloadInboundMedia(bot, contentStr) {
return await fetchDecryptRef(bot, contentStr);
}
/**
* 读取某会话最近一条入站图片消息,并下载/解密为 { buffer, ext }。
* 供「存图」命令与智能助手 save_image_to_knowledge 工具复用(用户发图后再说存知识库)。
* @param {object} bot bots 行(含 id)
* @param {string} peerId 会话对端 ID;为空则取该 bot 最近一张图片
* @returns {Promise<{buffer:Buffer, ext:string}|null>}
*/
async function loadLastInboundImage(bot, peerId) {
let row;
if (peerId) {
row = await db.row(
"SELECT content FROM messages WHERE bot_id=? AND peer_id=? AND direction='in' AND msg_type='image' ORDER BY id DESC LIMIT 1",
[bot.id, peerId]
);
}
if (!row) {
row = await db.row(
"SELECT content FROM messages WHERE bot_id=? AND direction='in' AND msg_type='image' ORDER BY id DESC LIMIT 1",
[bot.id]
);
}
if (!row || !row.content) return null;
return await downloadInboundImage(bot, row.content);
}
/**
* 将一张图片作为图片文件(media_type=9)写入知识库。
* 正确流程:create_media(拿 COS 临时凭证) → 腾讯云 COS 上传 → add_knowledge。
*
* @param {object} cfg
* @param {string} knowledgeBaseId
* @param {{buffer:Buffer, ext:string}} img
* @param {object} [opts] { folderId, title }
*/
async function addKnowledgeImage(cfg, knowledgeBaseId, img, opts = {}) {
const buffer = img.buffer;
const safeExt = (img.ext || 'jpg').replace(/[^a-z0-9]/gi, '').toLowerCase() || 'jpg';
const contentTypeMap = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml', heic: 'image/heic',
};
const contentType = contentTypeMap[safeExt] || 'image/jpeg';
const title = (opts.title || ('image_' + Date.now())).replace(/[\\\/:*?"<>|]/g, '_');
const file_name = title + '.' + safeExt;
let cm;
try {
cm = await createMedia(cfg, knowledgeBaseId, {
file_name,
file_size: buffer.length,
content_type: contentType,
file_ext: safeExt,
});
} catch (e) {
throw new Error('[create_media] ' + e.message);
}
const cred = cm.cos_credential;
try {
await uploadToCos(cred, cred.cos_key, buffer, contentType);
} catch (e) {
throw new Error('[COS上传] ' + e.message);
}
const body = {
media_type: 9, // 9 = 图片
media_id: cm.media_id,
title: file_name,
knowledge_base_id: knowledgeBaseId,
file_info: {
cos_key: cred.cos_key,
file_size: buffer.length,
file_name,
},
};
if (opts.folderId) body.folder_id = opts.folderId;
try {
const resp = await axios.post(IMA_API + '/add_knowledge', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '添加图片到知识库失败');
return data.data || {};
} catch (e) {
throw new Error('[add_knowledge] ' + e.message);
}
}
/** 获取知识库信息 */
async function getKnowledgeBaseInfo(cfg, ids) {
const resp = await axios.post(IMA_API + '/get_knowledge_base', { ids }, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '获取知识库信息失败');
return (data.data && data.data.infos) || {};
}
/* ==================== 笔记:读 ==================== */
/**
* 列出笔记(按笔记本,folderId 可空;空则全部笔记)
* 官方接口:/openapi/note/v1/list_note
* 返回归一化数组,每项含 note_id / title,兼容 smart 插件的解析。
*/
async function listNotes(cfg, folderId, limit = 10) {
const body = { cursor: '', limit: Math.min(limit, 20), sort_type: 0 };
if (folderId) body.folder_id = folderId;
const resp = await axios.post(NOTE_API + '/list_note', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '获取笔记列表失败');
const list = (data.data && data.data.note_book_list) || [];
return list.map(n => ({
note_id: n.note_id,
title: n.title || '无标题',
name: n.title || '',
summary: n.summary || '',
modify_time: n.modify_time || 0,
}));
}
/**
* 搜索笔记(按标题)
* 官方接口:/openapi/note/v1/search_note
* 返回归一化数组,每项含 note_id / title / highlight,兼容 smart 插件的解析。
*/
async function searchNotes(cfg, query, limit = 20) {
const end = Math.min(Math.max(limit, 1), 20);
const body = {
search_type: 0,
sort_type: 0,
query_info: { title: query },
start: 0,
end,
};
const resp = await axios.post(NOTE_API + '/search_note', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '搜索笔记失败');
const list = (data.data && data.data.search_note_infos) || [];
return list.map(it => {
const info = it.note_book_info || {};
return {
note_id: info.note_id,
title: info.title || '无标题',
name: info.title || '',
highlight: it.highlightInfo || {},
};
});
}
/**
* 读取单篇笔记纯文本
* 官方接口:/openapi/note/v1/get_doc_content(target_content_format=0 纯文本)
* 返回 { note_id, title, content }
*/
async function getNote(cfg, noteId) {
const resp = await axios.post(
NOTE_API + '/get_doc_content',
{ note_id: noteId, target_content_format: 0 },
{ headers: imaHeaders(cfg), timeout: 30000 }
);
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '获取笔记失败');
const content = (data.data && data.data.content) || '';
return { note_id: noteId, title: 'IMA 笔记', content };
}
/**
* 列出笔记本(notebook 目录)
* 官方接口:/openapi/note/v1/list_notebook
* 返回 NoteFolderInfo[](folder_id / name / note_number ...)
*/
async function listNotebooks(cfg, limit = 20) {
const body = { cursor: '0', limit: Math.min(limit, 20) };
const resp = await axios.post(NOTE_API + '/list_notebook', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '获取笔记本列表失败');
return (data.data && data.data.note_folder_infos) || [];
}
/* ==================== 笔记:写 ==================== */
/** 创建笔记 */
async function createNote(cfg, content, folderId) {
const body = { content_format: 1, content };
if (folderId) body.folder_id = folderId;
const resp = await axios.post(NOTE_API + '/import_doc', body, { headers: imaHeaders(cfg), timeout: 30000 });
const data = resp.data;
if (data.code !== 0) throw new Error(data.msg || '创建笔记失败');
return data.data || {};
}
module.exports = {
meta: {
id: 'ima-knowledge',
name: 'IMA 知识库',
version: '2.0.0',
author: '奶狗',
category: '信息获取',
description: '接入腾讯 IMA,支持知识库的搜索/列表/写入,以及笔记的列表/搜索/读取/创建。需在设置中填入 Client ID 和 API Key。',
entry: 'ima-knowledge/index.js',
// 字段化配置:每个机器人各存一份(plugin_settings 按 bot_id + plugin_id 隔离)
// 配置键由插件定义,用户只需填写对应的值
settingsSchema: [
{ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'IMA OpenAPI Client ID', help: 'IMA 开放平台应用的 Client ID。' },
{ key: 'api_key', label: 'API Key', type: 'password', placeholder: 'IMA OpenAPI API Key', help: 'IMA 开放平台的 API Key(密钥,界面以掩码显示)。' },
{ key: 'trigger', label: '触发前缀', type: 'text', placeholder: '知识库', help: '发送「前缀 命令」触发本插件,默认「知识库」。' },
],
},
async onMessage(msg, ctx) {
const botId = ctx.bot.id;
const text = (msg.content || '').trim();
// 图片消息:不再自动存入知识库。
// 用户必须明确表达「存到知识库」才会保存(智能助手走 save_image_to_knowledge 工具,
// 或手动发送「知识库 存图」命令保存最近一张图片)。这里直接放行不处理。
if (msg.msg_type === 'image') return false;
if (!text) return false;
const cfg = await loadSettings(botId);
// 笔记类命令(独立前缀)
if (text.startsWith('笔记') || text.startsWith('/笔记')) {
return await handleNote(text, cfg, ctx);
}
// 知识库类命令
const command = matchTrigger(text, cfg.trigger);
if (command === null) return false;
if (!cfg.client_id || !cfg.api_key) {
await ctx.sendText('[IMA] 请先配置 Client ID 和 API Key。\n在机器人管理 → 已安装插件 → 点击 IMA 知识库旁的设置按钮。\n\n获取方式:https://ima.qq.com 登录后进入设置 → API 管理');
return true;
}
try {
// 无参数 → 知识库列表
if (!command) return await handleKBList(cfg, ctx);
// 搜索
if (command.startsWith('搜索') || command.startsWith('search') || command.startsWith('查找')) {
let q = command;
if (q.startsWith('搜索')) q = q.slice(2).trim();
else if (q.startsWith('search')) q = q.slice(6).trim();
else if (q.startsWith('查找')) q = q.slice(2).trim();
return await handleKBSearch(cfg, ctx, q);
}
// 列表
if (command === '列表' || command === 'list') {
return await handleKBList(cfg, ctx);
}
// 导入网页链接到知识库:知识库 导入 url1,url2
if (command.startsWith('导入') || command.startsWith('import')) {
let rest = command;
if (rest.startsWith('导入')) rest = rest.slice(2).trim();
else if (rest.startsWith('import')) rest = rest.slice(6).trim();
return await handleKBImport(cfg, ctx, rest);
}
// 把图片URL作为图片文件存入知识库:知识库 存图 <url>
if (command.startsWith('存图') || command.startsWith('存图片') || command.startsWith('saveimg')) {
let rest = command;
if (rest.startsWith('存图')) rest = rest.slice(2).trim();
else if (rest.startsWith('存图片')) rest = rest.slice(3).trim();
else if (rest.startsWith('saveimg')) rest = rest.slice(7).trim();
return await handleKBSaveImage(cfg, ctx, rest);
}
// 写入文本到知识库:知识库 写入 标题 | 内容
if (command.startsWith('写入') || command.startsWith('添加') || command.startsWith('write')) {
let rest = command;
if (rest.startsWith('写入')) rest = rest.slice(2).trim();
else if (rest.startsWith('添加')) rest = rest.slice(2).trim();
else if (rest.startsWith('write')) rest = rest.slice(4).trim();
return await handleKBWrite(cfg, ctx, rest);
}
// 默认当作搜索
return await handleKBSearch(cfg, ctx, command);
} catch (err) {
console.error('[ima-knowledge] 错误:', err.message);
await ctx.sendText('[IMA] 请求失败:' + err.message);
return true;
}
},
// 导出给其他插件调用的公共函数
createNote,
importUrls,
addKnowledge,
addKnowledgeText,
addKnowledgeImage,
downloadImageFromUrl,
downloadInboundImage,
downloadInboundMedia,
loadLastInboundImage,
isImageUrl,
listNotebooks,
getKnowledgeBaseInfo,
listNotes,
searchNotes,
getNote,
searchKnowledgeBases,
searchInKnowledge,
getKnowledgeBaseList,
};
/* ==================== 知识库处理 ==================== */
/** 知识库列表 */
async function handleKBList(cfg, ctx) {
const bases = await getKnowledgeBaseList(cfg);
if (!bases.length) {
await ctx.sendText('[KB] IMA 知识库\n当前没有可用的知识库。');
return true;
}
const lines = ['[KB] 我的知识库', ''];
bases.forEach((b, i) => {
lines.push((i + 1) + '. ' + (b.name || b.id) + (b.id ? ' (id: ' + b.id + ')' : ''));
});
lines.push('');
lines.push('• ' + cfg.trigger + ' 搜索 关键词 → 搜索内容');
lines.push('• ' + cfg.trigger + ' 导入 url1,url2 → 加入网页/图片');
lines.push('• ' + cfg.trigger + ' 存图 [图片URL] → 存为图片文件(不填链接则存最近发的图片)');
lines.push('• ' + cfg.trigger + ' 写入 标题 | 内容 → 写入文本');
lines.push('• 发图片后说「存到知识库」/「' + cfg.trigger + ' 存图」→ 保存该图片');
await ctx.sendText(lines.join('\n'));
return true;
}
/** 知识库搜索(读) */
async function handleKBSearch(cfg, ctx, query) {
if (!query) {
await ctx.sendText('[KB] 请输入搜索关键词,如:「' + cfg.trigger + ' 搜索 部署文档」');
return true;
}
await ctx.sendText('[Ref] 正在搜索 IMA 知识库:「' + (query.length > 30 ? query.slice(0, 30) + '…' : query) + '」…');
const bases = await searchKnowledgeBases(cfg, query);
if (!bases.length) {
await ctx.sendText('[KB] 未找到匹配的知识库,请尝试其他关键词或使用「' + cfg.trigger + ' 列表」查看所有知识库。');
return true;
}
const kbId = bases[0].id;
const kbName = bases[0].name || kbId;
const results = await searchInKnowledge(cfg, kbId, query);
if (!results.length) {
await ctx.sendText('[KB] 「' + kbName + '」中未找到与「' + query + '」相关的内容。');
return true;
}
const lines = ['[KB] 知识库「' + kbName + '」搜索结果', ''];
const maxItems = Math.min(results.length, 5);
for (let i = 0; i < maxItems; i++) {
const item = results[i];
lines.push('▸ ' + (item.title || '无标题'));
if (item.highlight_content) {
const snippet = item.highlight_content
.replace(/<\/?em>/g, '')
.replace(/\s+/g, ' ')
.trim();
const short = snippet.length > 80 ? snippet.slice(0, 80) + '…' : snippet;
lines.push(' ' + short);
}
lines.push('');
}
if (results.length > maxItems) {
lines.push('… 还有 ' + (results.length - maxItems) + ' 条结果,请使用更精确的关键词搜索');
}
await ctx.sendText(lines.join('\n'));
return true;
}
/** 知识库导入网页 / 图片(写)。图片URL会作为图片文件存入,其余按网页链接导入。 */
async function handleKBImport(cfg, ctx, rest) {
if (!rest) {
await ctx.sendText('[Link] 用法:' + cfg.trigger + ' 导入 https://a.com,https://b.com');
return true;
}
const urls = rest.split(/[,\n,]/).map(s => s.trim()).filter(s => /^https?:\/\//i.test(s));
if (!urls.length) {
await ctx.sendText('[WARN] 未识别到有效的 http(s) 链接。');
return true;
}
const bases = await getKnowledgeBaseList(cfg);
if (!bases.length) {
await ctx.sendText('[KB] 没有可用的知识库,无法导入。');
return true;
}
const kbId = bases[0].id;
const kbName = bases[0].name || kbId;
const imageUrls = urls.filter(isImageUrl);
const linkUrls = urls.filter(u => !isImageUrl(u));
const parts = [];
if (linkUrls.length) {
await ctx.sendText('[Upload] 正在把 ' + linkUrls.length + ' 个网页链接导入「' + kbName + '」…');
try {
await importUrls(cfg, kbId, linkUrls);
parts.push('网页链接 ' + linkUrls.length + ' 个');
} catch (e) {
parts.push('网页链接失败: ' + e.message);
}
}
if (imageUrls.length) {
await ctx.sendText('[Upload] 正在把 ' + imageUrls.length + ' 张图片作为文件存入「' + kbName + '」…');
let ok = 0;
for (const u of imageUrls) {
try {
const img = await downloadImageFromUrl(u);
await addKnowledgeImage(cfg, kbId, img);
ok++;
} catch (e) {
parts.push('图片(' + u + ')失败: ' + e.message);
}
}
if (ok) parts.push('图片 ' + ok + ' 张');
}
await ctx.sendText('[OK] 已处理:' + parts.join(';'));
return true;
}
/** 把图片URL作为图片文件存入知识库(写);不带 URL 时存入最近一张收到的图片 */
async function handleKBSaveImage(cfg, ctx, rest) {
const bases = await getKnowledgeBaseList(cfg);
if (!bases.length) {
await ctx.sendText('[KB] 没有可用的知识库,无法存入。');
return true;
}
const kbId = bases[0].id;
const kbName = bases[0].name || kbId;
// 无 URL → 存入最近一张收到的图片
if (!rest) {
await ctx.sendText('[Upload] 正在把你最近发送的图片存入「' + kbName + '」…');
try {
const img = await loadLastInboundImage(ctx.bot, ctx.msg && ctx.msg.peer_id);
if (!img || !img.buffer || !img.buffer.length) {
await ctx.sendText('[WARN] 没有找到最近的图片。请先发送一张图片,再发送「' + cfg.trigger + ' 存图」。\n或直接:' + cfg.trigger + ' 存图 https://图片链接');
return true;
}
await addKnowledgeImage(cfg, kbId, img);
await ctx.sendText('[OK] 已把最近的图片存入「' + kbName + '」。');
} catch (e) {
await ctx.sendText('[IMA] 存图失败:' + e.message);
}
return true;
}
const urls = rest.split(/[,\n,]/).map(s => s.trim()).filter(s => /^https?:\/\//i.test(s));
if (!urls.length) {
await ctx.sendText('[WARN] 未识别到有效的 http(s) 图片链接。');
return true;
}
await ctx.sendText('[Upload] 正在把 ' + urls.length + ' 张图片作为文件存入「' + kbName + '」…');
let ok = 0;
const fails = [];
for (const u of urls) {
try {
const img = await downloadImageFromUrl(u);
await addKnowledgeImage(cfg, kbId, img);
ok++;
} catch (e) {
fails.push(u + ': ' + e.message);
}
}
await ctx.sendText('[OK] 已存入图片 ' + ok + ' 张' + (fails.length ? ',失败:' + fails.join(';') : '') + ' 到「' + kbName + '」。');
return true;
}
/** 知识库写入文本(写) */
async function handleKBWrite(cfg, ctx, rest) {
if (!rest) {
await ctx.sendText('[Write] 用法:' + cfg.trigger + ' 写入 标题 | 正文内容');
return true;
}
// 标题 | 内容
let title, content;
const sep = rest.indexOf('|');
if (sep > -1) {
title = rest.slice(0, sep).trim();
content = rest.slice(sep + 1).trim();
} else {
title = rest.slice(0, 20);
content = rest;
}
if (!content) {
await ctx.sendText('[WARN] 内容不能为空。用法:' + cfg.trigger + ' 写入 标题 | 正文内容');
return true;
}
const bases = await getKnowledgeBaseList(cfg);
if (!bases.length) {
await ctx.sendText('[KB] 没有可用的知识库,无法写入。');
return true;
}
const kbId = bases[0].id;
const kbName = bases[0].name || kbId;
await ctx.sendText('[Upload] 正在写入「' + kbName + '」…');
const res = await addKnowledgeText(cfg, kbId, title, content);
await ctx.sendText('[OK] 已写入知识库,结果:' + JSON.stringify(res).slice(0, 200));
return true;
}
/* ==================== 笔记处理 ==================== */
/** 笔记命令分发 */
async function handleNote(text, cfg, ctx) {
if (!cfg.client_id || !cfg.api_key) {
await ctx.sendText('[IMA] 请先配置 Client ID 和 API Key。\n在机器人管理 → 已安装插件 → 点击 IMA 知识库旁的设置按钮。');
return true;
}
// 去掉 "笔记"/"/笔记" 前缀
let cmd = text;
if (cmd.startsWith('/笔记')) cmd = cmd.slice(3).trim();
else if (cmd.startsWith('笔记')) cmd = cmd.slice(2).trim();
try {
// 无参数 → 列表
if (!cmd) return await handleNoteList(cfg, ctx);
if (cmd.startsWith('列表') || cmd.startsWith('list')) {
return await handleNoteList(cfg, ctx);
}
if (cmd.startsWith('搜索') || cmd.startsWith('search') || cmd.startsWith('查找')) {
let q = cmd;
if (q.startsWith('搜索')) q = q.slice(2).trim();
else if (q.startsWith('search')) q = q.slice(6).trim();
else if (q.startsWith('查找')) q = q.slice(2).trim();
return await handleNoteSearch(cfg, ctx, q);
}
if (cmd.startsWith('读取') || cmd.startsWith('get') || cmd.startsWith('读')) {
let id = cmd;
if (id.startsWith('读取')) id = id.slice(2).trim();
else if (id.startsWith('get')) id = id.slice(3).trim();
else if (id.startsWith('读')) id = id.slice(1).trim();
return await handleNoteGet(cfg, ctx, id);
}
if (cmd.startsWith('创建') || cmd.startsWith('create') || cmd.startsWith('写') || cmd.startsWith('新建')) {
let content = cmd;
if (content.startsWith('创建')) content = content.slice(2).trim();
else if (content.startsWith('create')) content = content.slice(6).trim();
else if (content.startsWith('新建')) content = content.slice(2).trim();
else if (content.startsWith('写')) content = content.slice(1).trim();
return await handleNoteCreate(cfg, ctx, content);
}
if (cmd.startsWith('笔记本') || cmd.startsWith('notebook')) {
return await handleNoteNotebooks(cfg, ctx);
}
// 默认当作搜索
return await handleNoteSearch(cfg, ctx, cmd);
} catch (err) {
console.error('[ima-knowledge] 笔记错误:', err.message);
await ctx.sendText('[IMA 笔记] 请求失败:' + err.message);
return true;
}
}
/** 笔记列表(读) */
async function handleNoteList(cfg, ctx) {
const notes = await listNotes(cfg);
if (!notes.length) {
await ctx.sendText('[Note] 暂无笔记。\n发送「笔记 创建 内容」新建一篇笔记。');
return true;
}
const lines = ['[Note] 我的笔记', ''];
notes.slice(0, 10).forEach((n, i) => {
const title = n.title || n.name || (n.doc && n.doc.basic_info && n.doc.basic_info.title) || '无标题';
const id = n.note_id || n.id || '';
lines.push((i + 1) + '. ' + title + (id ? ' (id: ' + id + ')' : ''));
});
lines.push('');
lines.push('• 笔记 搜索 关键词');
lines.push('• 笔记 读取 <id> → 查看全文');
lines.push('• 笔记 创建 内容 → 新建');
lines.push('• 笔记 笔记本 → 笔记本列表');
await ctx.sendText(lines.join('\n'));
return true;
}
/** 笔记搜索(读) */
async function handleNoteSearch(cfg, ctx, query) {
if (!query) {
await ctx.sendText('[Ref] 用法:笔记 搜索 关键词');
return true;
}
const notes = await searchNotes(cfg, query);
if (!notes.length) {
await ctx.sendText('[Note] 未找到与「' + query + '」相关的笔记。');
return true;
}
const lines = ['[Note] 笔记搜索「' + query + '」', ''];
notes.slice(0, 10).forEach((n, i) => {
const title = n.title || n.name || (n.doc && n.doc.basic_info && n.doc.basic_info.title) || '无标题';
const id = n.note_id || n.id || '';
lines.push((i + 1) + '. ' + title + (id ? ' (id: ' + id + ')' : ''));
});
lines.push('');
lines.push('发送「笔记 读取 <id>」查看全文');
await ctx.sendText(lines.join('\n'));
return true;
}
/** 读取单篇笔记(读) */
async function handleNoteGet(cfg, ctx, noteId) {
if (!noteId) {
await ctx.sendText('[Read] 用法:笔记 读取 <笔记ID>');
return true;
}
const note = await getNote(cfg, 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) {
await ctx.sendText('[Read] 《' + title + '》\n(无正文内容,可能该接口需要不同参数,请反馈)');
return true;
}
// 长内容分段
const MAX = 1500;
if (content.length <= MAX) {
await ctx.sendText('[Read] 《' + title + '》\n\n' + content);
} else {
await ctx.sendText('[Read] 《' + title + '》(上)\n\n' + content.slice(0, MAX));
await ctx.sendText('[Read] 《' + title + '》(下)\n\n' + content.slice(MAX));
}
return true;
}
/** 创建笔记(写) */
async function handleNoteCreate(cfg, ctx, content) {
if (!content) {
await ctx.sendText('[Write] 用法:笔记 创建 这里写笔记内容');
return true;
}
await ctx.sendText('[Upload] 正在创建笔记…');
const res = await createNote(cfg, content);
const id = res.note_id || res.doc_id || '';
await ctx.sendText('[OK] 笔记已创建' + (id ? '(id: ' + id + ')' : '') + '。\n发送「笔记 列表」可查看。');
return true;
}
/** 笔记本列表(读) */
async function handleNoteNotebooks(cfg, ctx) {
const folders = await listNotebooks(cfg);
if (!folders.length) {
await ctx.sendText('[KB] 暂无笔记本。');
return true;
}
const lines = ['[KB] 我的笔记本', ''];
folders.forEach((f, i) => {
lines.push((i + 1) + '. ' + (f.name || '未命名') + ' (' + (f.note_number || 0) + ' 篇)');
});
await ctx.sendText(lines.join('\n'));
return true;
}