码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
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;
}