码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js32.5 KB
/**
 * 用户画像(原「超级记忆」)
 * --------------------------------------------------
 * 基于用户与机器人的聊天记录,自动分析「用户是个什么样的人」,
 * 生成结构化用户画像(用户画像),并注入到 AI 对话中以让回复更懂用户。
 *
 * 设计原则:
 *  - 隐私优先(opt-out):默认对「聊够天数」的用户自动分析聊天记录生成画像;
 *    用户发送「拒绝画像」即停止并删除,未拒绝则始终自动生成(无需主动授权)。
 *  - 全自动后台:周期性(默认每30分钟)扫描有聊天且未拒绝的用户,若有新聊天则生成/更新画像。
 *  - 注入对话:getCombinedPrompt 把该用户的最新画像附加到 AI 系统提示。
 *
 * 数据库:
 *  - user_memory_consent(bot_id, peer_id, consent, at, last_profile_at)
 *    consent: 0=拒绝 1=同意 null=未表态;last_profile_at 为秒级时间戳。
 *  - 画像文件:data/memory/profile_<botId>_<peerId>.md
 *  - user_facts(bot_id, peer_id, fkey, fvalue, updated_at):结构化「长期事实记忆」
 *    (城市/星座/生日/职业/偏好等)。由 AI 主动调用 save_user_fact 实时写入,
 *    或由画像生成时的信息抽取写入;每次对话通过 getCombinedPrompt 自动注入 AI
 *    系统提示,实现「聊天时自动搜索记忆」(如记住地址后天气/星座免重复询问)。
 */
const fs = require('fs');
const path = require('path');
const db = require('../../lib/db');
const pluginsLib = require('../../lib/plugins');

/**
 * 归一化 peer_id:iLink 同一微信用户可能以不同后缀形式上报
 * (如 `o9...QjW2djA_im_wechat` 与 `[email protected]`),
 * 统一剥掉已知后缀、仅保留 openid 作为规范身份,避免被当成多个用户、生成重复画像。
 * 注意:messages.peer_id 仍保留原始值(发消息回信用得到),仅在「记忆身份」维度归一化。
 */
function normalizePeer(p) {
  if (!p) return p;
  return String(p)
    .replace(/_im_wechat$/i, '')
    .replace(/@im\.wechat$/i, '');
}
/** 匹配某规范 peer 的所有原始变体(用于查询 messages 表,其 peer_id 为原始值) */
function peerMatch(np) {
  return '(peer_id = ? OR peer_id LIKE ? OR peer_id LIKE ?)';
}
function peerMatchParams(np) {
  return [np, np + '_%', np + '@%'];
}

const meta = {
  id: 'memory',
  name: '用户画像',
  version: '2.0.0',
  author: '奶狗',
  category: 'AI对话',
  description: '基于用户与机器人的聊天记录,自动分析并生成「用户画像」(你是怎样的人、兴趣、风格、需求等),注入 AI 对话让回复更懂用户。默认自动生成(opt-out),用户发送「拒绝画像」即可关闭。',
  entry: 'memory/index.js',
  builtin: true,
  // 智能助手 function calling 工具:让 AI 在聊天中实时存取用户事实记忆
  aiTools: [
    {
      function: {
        name: 'save_user_fact',
        description: '记住关于当前用户的一个长期事实或偏好(例如:常住城市、星座、生日、职业、饮食习惯、常用语言等)。当用户在对话中透露个人信息或偏好时,应主动调用本工具保存,以便后续对话无需重复询问。',
        parameters: {
          type: 'object',
          properties: {
            key: { type: 'string', description: '事实类别/键,如 城市、星座、生日、职业、偏好' },
            value: { type: 'string', description: '该事实的具体值,如 北京、白羊座、3月21日、程序员' },
          },
          required: ['key', 'value'],
        },
      },
    },
    {
      function: {
        name: 'recall_user_facts',
        description: '回忆/检索已记住的关于当前用户的事实(如城市、星座等)。当用户问「你还记得我什么」「我的城市是?」,或需要用到已记住的信息(例如查天气前需先知道城市)时调用。',
        parameters: {
          type: 'object',
          properties: {
            query: { type: 'string', description: '可选,按关键词过滤,如 城市、星座;留空返回全部已记住的事实' },
          },
          required: [],
        },
      },
    },
  ],
};

const MEM_DIR = path.join(__dirname, '..', '..', 'data', 'memory');
if (!fs.existsSync(MEM_DIR)) fs.mkdirSync(MEM_DIR, { recursive: true });

// 调度器防重入标志(模块只加载一次,顶层 setInterval 只起一个)
let schedulerStarted = false;
let generating = false; // 生成画像期间禁止把画像注入到生成请求本身(避免循环)

const SCHEDULER_INTERVAL_MS = 30 * 60 * 1000; // 每30分钟扫描一次
const MIN_GEN_INTERVAL_SEC = 6 * 3600;          // 同一用户两次生成至少间隔6小时
const MIN_MESSAGES = 5;                           // 聊天记录不足则跳过
const FETCH_LIMIT = 400;                          // 单次分析取最近多少条用户发言
const MAX_TRANSCRIPT_CHARS = 16000;               // 发给 AI 的聊天记录最大字符数(约 5k token,超出取最近部分,避免推理超时)
const MANUAL_FETCH_LIMIT = 800;                   // 手动「立即生成」时取最近多少条(比自动调度多,但受 MAX_TRANSCRIPT_CHARS 封顶)

function profilePath(botId, peerId) {
  const safe = String(normalizePeer(peerId)).replace(/[^\w-]/g, '_');
  return path.join(MEM_DIR, `profile_${botId}_${safe}.md`);
}

async function ensureTables() {
  await db.exec(`
    CREATE TABLE IF NOT EXISTS user_memory_consent (
      bot_id TEXT NOT NULL,
      peer_id TEXT NOT NULL,
      consent INTEGER,
      at INTEGER,
      last_profile_at INTEGER DEFAULT 0,
      PRIMARY KEY (bot_id, peer_id)
    );
  `);
  // 老表兼容:补齐 last_profile_at 列
  try {
    await db.exec('ALTER TABLE user_memory_consent ADD COLUMN last_profile_at INTEGER DEFAULT 0');
  } catch (e) { /* 已存在则忽略 */ }
  // 老表兼容:补齐 at 列(早期版本建表可能无此列,导致 setConsent 写入报错)
  try {
    await db.exec('ALTER TABLE user_memory_consent ADD COLUMN at INTEGER');
  } catch (e) { /* 已存在则忽略 */ }

  // 结构化事实记忆表(城市/星座/生日/偏好等,key-value 按用户维度)
  await db.exec(`
    CREATE TABLE IF NOT EXISTS user_facts (
      bot_id TEXT NOT NULL,
      peer_id TEXT NOT NULL,
      fkey TEXT NOT NULL,
      fvalue TEXT NOT NULL,
      updated_at INTEGER DEFAULT 0,
      PRIMARY KEY (bot_id, peer_id, fkey)
    );
  `);
}
// 先确保表/列就绪,再启动后台调度(避免 ALTER 加列完成前就被调度器查询到不存在的列)
(async () => {
  try { await ensureTables(); }
  catch (e) { console.error('[memory] 初始化表失败:', e.message); }
  startScheduler();
})();

async function getConsent(botId, peerId) {
  peerId = normalizePeer(peerId);
  const r = await db.row('SELECT consent FROM user_memory_consent WHERE bot_id=? AND peer_id=?', [botId, peerId]);
  return r ? r.consent : null;
}

async function setConsent(botId, peerId, v) {
  peerId = normalizePeer(peerId);
  const now = Math.floor(Date.now() / 1000);
  await db.exec(
    `INSERT INTO user_memory_consent(bot_id, peer_id, consent, at) VALUES(?,?,?,?)
     ON CONFLICT(bot_id, peer_id) DO UPDATE SET consent=?, at=?`,
    [botId, peerId, v, now, v, now]
  );
}

async function getLastProfileAt(botId, peerId) {
  peerId = normalizePeer(peerId);
  const r = await db.row('SELECT last_profile_at FROM user_memory_consent WHERE bot_id=? AND peer_id=?', [botId, peerId]);
  return r && r.last_profile_at ? Number(r.last_profile_at) : 0;
}

async function setLastProfileAt(botId, peerId, tsSec) {
  peerId = normalizePeer(peerId);
  await db.exec(
    `INSERT INTO user_memory_consent(bot_id, peer_id, last_profile_at) VALUES(?,?,?)
     ON CONFLICT(bot_id, peer_id) DO UPDATE SET last_profile_at=?`,
    [botId, peerId, tsSec, tsSec]
  );
}

// ==================== 结构化事实记忆(user_facts) ====================
/** 保存/更新一条用户事实(key-value 按用户维度 upsert) */
async function saveFact(botId, peerId, key, value) {
  peerId = normalizePeer(peerId);
  const k = String(key == null ? '' : key).trim();
  const v = String(value == null ? '' : value).trim();
  if (!k || !v) return;
  const now = Math.floor(Date.now() / 1000);
  await db.exec(
    `INSERT INTO user_facts(bot_id, peer_id, fkey, fvalue, updated_at) VALUES(?,?,?,?,?)
     ON CONFLICT(bot_id, peer_id, fkey) DO UPDATE SET fvalue=?, updated_at=?`,
    [botId, peerId, k, v, now, v, now]
  );
}

/** 取该用户全部事实(时间升序) */
async function getAllFacts(botId, peerId) {
  peerId = normalizePeer(peerId);
  return await db.rows(
    'SELECT fkey, fvalue FROM user_facts WHERE bot_id=? AND peer_id=? ORDER BY updated_at ASC',
    [botId, peerId]
  );
}

/** 按关键词检索事实(key 或 value 命中);query 为空返回全部 */
async function searchFacts(botId, peerId, query) {
  peerId = normalizePeer(peerId);
  if (!query) return getAllFacts(botId, peerId);
  const q = '%' + String(query).trim() + '%';
  return await db.rows(
    'SELECT fkey, fvalue FROM user_facts WHERE bot_id=? AND peer_id=? AND (fkey LIKE ? OR fvalue LIKE ?) ORDER BY updated_at ASC',
    [botId, peerId, q, q]
  );
}

async function isEnabled(botId) {
  try {
    const s = await db.row('SELECT value FROM settings WHERE key=?', ['memory_enabled']);
    if (s) return s.value === '1';
  } catch (e) { /* 忽略 */ }
  return true; // 默认开启
}

/** 取某用户最近的文本发言(秒级 created_at,时间正序)。
 *  @param {number} [sinceTs] 若提供,则只取 created_at > sinceTs 的新聊天(增量更新用) */
async function fetchUserMessages(botId, peerId, limit, sinceTs) {
  const np = normalizePeer(peerId);
  const rows = await db.rows(
    `SELECT content FROM messages
     WHERE bot_id=? AND (${peerMatch(np)}) AND direction='in'
       AND (msg_type='text' OR msg_type IS NULL OR msg_type='')
       AND content IS NOT NULL AND TRIM(content)<>''
       ${sinceTs ? 'AND created_at > ?' : ''}
     ORDER BY created_at DESC LIMIT ?`,
    [botId, ...peerMatchParams(np), ...(sinceTs ? [sinceTs] : []), limit || FETCH_LIMIT]
  );
  return rows.reverse().map(r => String(r.content).trim()).filter(Boolean);
}

/**
 * 调用 AI 基于聊天记录生成/更新用户画像,写入 MD 文件。返回 true(成功) / 'skipped'(已生成过且暂无新增聊天) / false(失败或聊天不足)。
 * @param {string} botId
 * @param {string} peerId
 * @param {object} [opts] { limit } 分析的聊天条数上限;手动触发时可传很大值以覆盖「所有聊天记录」
 */
async function generateProfile(botId, peerId, opts) {
  if (generating) return false; // 防重入
  const limit = Math.min(opts && opts.limit ? opts.limit : FETCH_LIMIT, MANUAL_FETCH_LIMIT);

  // 已生成过 → 增量模式:只取「上次生成之后」的新聊天,避免把全部历史重复分析一遍。
  // force=true(手动「重新生成」)时忽略增量水印,基于全部聊天做全量重算,确保哪怕只有 1 条新消息也被纳入。
  let lastAt = 0;
  try { lastAt = await getLastProfileAt(botId, peerId); } catch (e) { console.error('[memory] getLastProfileAt 失败', botId, peerId, e.message); }
  const hasProfile = fs.existsSync(profilePath(botId, peerId));
  const force = !!(opts && opts.force);
  const sinceTs = (!force && hasProfile && lastAt > 0) ? lastAt : null;
  let msgs = [];
  try {
    msgs = await fetchUserMessages(botId, peerId, limit, sinceTs);
  } catch (e) {
    console.error('[memory] fetchUserMessages 失败', botId, peerId, e.message);
    return false;
  }
  if (msgs.length < MIN_MESSAGES) {
    // 全量/强制:聊天总数不足才跳过;增量:新增不足则跳过(避免无意义的 AI 调用)
    return sinceTs ? 'skipped' : false;
  }

  let transcript = msgs.map((m, i) => `${i + 1}. ${m}`).join('\n');
  if (transcript.length > MAX_TRANSCRIPT_CHARS) {
    transcript = '…(较早的聊天记录已省略)\n' + transcript.slice(-MAX_TRANSCRIPT_CHARS);
  }

  let systemPrompt, prompt, sampleDesc;
  if (sinceTs) {
    // 增量更新:在已有画像基础上结合新聊天产出最新完整画像
    let prev = '';
    try { prev = fs.readFileSync(profilePath(botId, peerId), 'utf8'); } catch (e) { /* ignore */ }
    sampleDesc = `上次生成后新增 ${msgs.length} 条聊天(增量更新)`;
    systemPrompt =
      '你是一名资深用户研究分析师。下面是你之前为该用户生成的「用户画像」,以及该用户在上次生成画像之后新产生的聊天记录(仅用户发言,按时间先后排列)。\n' +
      '请结合新聊天,更新并输出该用户最新的完整画像,使用简体中文、Markdown 格式。要求:\n' +
      '- 从以下维度刻画(信息不足时直接省略该维度,不要编造、不要套话):\n' +
      '  1. 身份与角色(可能的职业/身份/年龄段线索)\n' +
      '  2. 性格特质与沟通风格\n' +
      '  3. 兴趣与关注领域\n' +
      '  4. 价值观与关注点\n' +
      '  5. 痛点、需求与未被满足的期待\n' +
      '  6. 知识水平与表达习惯\n' +
      '  7. 与 AI 助手互动的偏好(如希望简洁/详细、是否爱用指令等)\n' +
      '- 客观、具体、有洞察;信息不足维度直接省略;控制在 300~600 字之间。';
    prompt =
      '【已有的用户画像】\n' + (prev || '(无)') + '\n\n' +
      '【上次生成之后的新聊天记录】\n\n' + transcript + '\n\n' +
      '请输出更新后的最新完整画像(不要复述聊天内容本身,只输出画像分析):';
  } else {
    // 首次 / 全量生成
    sampleDesc = `${msgs.length} 条聊天`;
    systemPrompt =
      '你是一名资深用户研究分析师。下面是一段「用户与 AI 助手」的聊天记录(仅用户发言,按时间先后排列)。\n' +
      '请分析并输出该用户的「用户画像」,使用简体中文、Markdown 格式。\n' +
      '要求:\n' +
      '- 从以下维度刻画(信息不足时直接省略该维度,不要编造、不要套话):\n' +
      '  1. 身份与角色(可能的职业/身份/年龄段线索)\n' +
      '  2. 性格特质与沟通风格\n' +
      '  3. 兴趣与关注领域\n' +
      '  4. 价值观与关注点\n' +
      '  5. 痛点、需求与未被满足的期待\n' +
      '  6. 知识水平与表达习惯\n' +
      '  7. 与 AI 助手互动的偏好(如希望简洁/详细、是否爱用指令等)\n' +
      '- 客观、具体、有洞察;多用要点+简短说明。\n' +
      '- 控制在 300~600 字之间。';
    prompt =
      '以下是该用户的聊天记录:\n\n' + transcript + '\n\n请基于以上聊天记录,生成该用户的画像(不要复述聊天内容本身,只输出画像分析):';
  }

  generating = true;
  try {
    const profile = await pluginsLib.callAssistant({ botId, userId: peerId, prompt, systemPrompt });
    if (profile && profile.trim()) {
      const ts = new Date().toLocaleString('zh-CN');
      fs.writeFileSync(
        profilePath(botId, peerId),
        `# 用户画像(bot:${botId} peer:${peerId})\n\n> 生成时间:${ts} | ${sampleDesc}\n\n${profile.trim()}\n`
      );
      // 水印用「实际最新消息时间」,而非生成时刻 now,避免水印超前导致边界消息漏检
      await setLastProfileAt(botId, peerId, await getLastChatAt(botId, peerId));
      // 同步抽取结构化事实(城市/星座/生日/偏好…)写入 user_facts,供对话实时记忆使用
      await extractFacts(botId, peerId, msgs);
      return true;
    }
  } catch (e) {
    console.error('[memory] 生成画像失败', botId, peerId, e.message);
  } finally {
    generating = false;
  }
  return false;
}

/**
 * 从聊天记录中抽取结构化「事实/偏好」并写入 user_facts(与 AI 主动 save_user_fact 共享同一张表)。
 * 这样即便 AI 未在聊天中实时存档,后台画像重算时也能把稳定事实补进记忆卡片。
 */
async function extractFacts(botId, peerId, msgs) {
  if (!msgs || !msgs.length) return;
  let transcript = msgs.map((m, i) => `${i + 1}. ${m}`).join('\n');
  if (transcript.length > MAX_TRANSCRIPT_CHARS) transcript = transcript.slice(-MAX_TRANSCRIPT_CHARS);
  const systemPrompt =
    '你是信息抽取助手。请从下面的用户聊天记录中抽取该用户的「稳定事实与长期偏好」。\n' +
    '只输出能从聊天中确切推断出的事实,每行一个,格式严格为 `键: 值`,例如:\n' +
    '城市: 北京\n星座: 白羊座\n生日: 3月21日\n职业: 程序员\n常用语言: 中文\n' +
    '不要编造、不要输出分析或解释、不要使用项目符号或编号。若没有任何可抽取的事实,直接输出空内容。';
  const prompt = '用户聊天记录:\n\n' + transcript + '\n\n请抽取事实(每行 `键: 值`):';
  try {
    const out = await pluginsLib.callAssistant({ botId, userId: peerId, prompt, systemPrompt });
    if (!out) return;
    const lines = String(out).split(/\n+/).map(s => s.trim()).filter(Boolean);
    for (const line of lines) {
      const idx = line.indexOf(':');
      if (idx <= 0) continue;
      const key = line.slice(0, idx).trim().replace(/^[-*\s]+/, '');
      const value = line.slice(idx + 1).trim();
      if (key && value) await saveFact(botId, peerId, key, value);
    }
  } catch (e) {
    console.error('[memory] 事实抽取失败', botId, peerId, e.message);
  }
}

/** 后台调度:扫描所有「聊够天数且未拒绝」的用户,有新聊天且满足间隔则生成/更新画像 */
async function runScheduler() {
  try {
    // opt-out 自动模式:对所有有足够聊天记录的入站用户生成画像,仅跳过明确拒绝(consent=0)者
    const raw = await db.rows(
      `SELECT bot_id, peer_id FROM messages WHERE direction='in' AND msg_type='text' GROUP BY bot_id, peer_id HAVING COUNT(*) >= ?`,
      [MIN_MESSAGES]
    );
    const nowSec = Math.floor(Date.now() / 1000);
    const seen = new Set();
    for (const row of raw) {
      const np = normalizePeer(row.peer_id);
      const key = row.bot_id + '|' + np;
      if (seen.has(key)) continue; // 同一规范用户只处理一次(避免 _im_wechat / @im.wechat 被视为两人)
      seen.add(key);
      const consent = await getConsent(row.bot_id, np);
      if (Number(consent) === 0) continue; // 已拒绝,跳过
      const last = await getLastProfileAt(row.bot_id, np);
      if (nowSec - last < MIN_GEN_INTERVAL_SEC) continue; // 节流
      // 是否有新聊天(匹配该规范用户的所有原始 from 变体)
      const newest = await getLastChatAt(row.bot_id, np);
      if (newest <= last) continue; // 无新消息
      if (generating) continue;
      await generateProfile(row.bot_id, np);
    }
  } catch (e) {
    console.error('[memory] 调度器异常', e.message);
  }
}

/** 列出某 bot 下有足够聊天记录的用户及其授权状态(供后台手动批量生成用) */
async function listChatUsers(botId) {
  const rows = await db.rows(
    `SELECT peer_id, COUNT(*) c FROM messages
     WHERE bot_id=? AND direction='in' AND msg_type='text'
       AND content IS NOT NULL AND TRIM(content)<>''
     GROUP BY peer_id HAVING c >= ?`,
    [botId, MIN_MESSAGES]
  );
  // 按规范 peer 合并(同一微信用户的不同 from 后缀视为同一人)
  const map = new Map();
  for (const r of rows) {
    const np = normalizePeer(r.peer_id);
    if (!map.has(np)) map.set(np, { peer_id: np, count: 0 });
    map.get(np).count += Number(r.c);
  }
  const out = [];
  for (const np of map.keys()) {
    const c = await db.row('SELECT consent FROM user_memory_consent WHERE bot_id=? AND peer_id=?', [botId, np]);
    out.push({ peer_id: np, consent: c && c.consent != null ? Number(c.consent) : -1 });
  }
  return out;
}

/**
 * 后台「手动授权/立即生成」:对该 bot 下所有未拒绝的聊天用户,基于其全部聊天记录立即生成/更新画像。
 * 不受 6 小时节流限制。返回统计 { total, ok, skip, fail }。
 */
async function generateAllProfiles(botId, opts) {
  const users = await listChatUsers(botId);
  const limit = (opts && opts.limit) || 100000;
  let ok = 0, skip = 0, fail = 0;
  for (const usr of users) {
    if (Number(usr.consent) === 0) { skip++; continue; } // 已拒绝,尊重意愿
    try {
      const done = await generateProfile(botId, usr.peer_id, { limit });
      if (done === true) ok++; else if (done === 'skipped') skip++; else fail++;
    } catch (e) {
      fail++;
      console.error('[memory] 批量生成失败', botId, usr.peer_id, e.message);
    }
  }
  return { total: users.length, ok, skip, fail };
}

function startScheduler() {
  if (schedulerStarted) return;
  schedulerStarted = true;
  runScheduler();
  setInterval(runScheduler, SCHEDULER_INTERVAL_MS).unref();
}

/**
 * 注入 AI 对话的系统提示(被 smart.collectPluginPrompts 调用)。
 * 仅在「已启用 + 未拒绝 + 已生成画像」时返回画像文本(opt-out:未授权用户也注入)。
 * @param {string} botId
 * @param {string} [peerId]
 */
async function getCombinedPrompt(botId, peerId) {
  try {
    if (generating) return ''; // 正在生成画像/抽取事实,避免循环注入
    const enabled = await isEnabled(botId);
    if (!enabled || !peerId) return '';
    peerId = normalizePeer(peerId);
    const consent = await getConsent(botId, peerId);
    if (consent !== null && Number(consent) === 0) return ''; // 仅已拒绝时不注入
    const parts = [];
    // 1) 人物侧写(画像文件)
    const file = profilePath(botId, peerId);
    if (fs.existsSync(file)) {
      const text = fs.readFileSync(file, 'utf8');
      const body = text.split('\n').slice(3).join('\n').trim(); // 去掉标题/时间行
      if (body) {
        parts.push('[用户画像·仅供理解该用户,勿在回复中提及「画像」二字]\n' + body + '\n[用户画像结束]');
      }
    }
    // 2) 结构化记忆卡片(user_facts 事实,对话中可直接使用,无需重复询问)
    const facts = await getAllFacts(botId, peerId);
    if (facts.length) {
      const lines = facts.map(r => `- ${r.fkey}: ${r.fvalue}`).join('\n');
      parts.push('[用户记忆卡片·已记住的关于该用户的事实,对话中可直接使用,无需再向用户询问]\n' + lines + '\n[用户记忆卡片结束]');
    }
    return parts.join('\n\n');
  } catch (e) {
    return '';
  }
}

async function handleCommand(bot, msg, ctx) {
  const text = (msg && msg.content || '').trim();
  if (!text) return false;
  const botId = String(bot && bot.id != null ? bot.id : (msg.bot_id != null ? msg.bot_id : ''));
  const peerId = normalizePeer(String(msg.peer_id || ''));
  if (!peerId) return false;

  const consent = await getConsent(botId, peerId);

  // 开启 / 关闭
  if (/^(同意|开启|开通|我愿意|允许|好的|可以|没问题|接受|行|要)/.test(text) && /(画像|分析|记忆|了解|用户)/.test(text)) {
    await setConsent(botId, peerId, 1);
    await ctx.sendText('✅ 已开启「用户画像」。\n系统会在后台自动分析你与机器人的聊天记录,逐步生成并持续更新你的画像,用于让回复更懂你。\n随时发送「拒绝画像」可关闭并停止分析。');
    return true;
  }
  if (/^(拒绝|不开通|不用|关闭|不用了|不需要|禁止|不同意|取消|别)/.test(text) && /(画像|分析|记忆|了解|用户)/.test(text)) {
    await setConsent(botId, peerId, 0);
    const f = profilePath(botId, peerId);
    if (fs.existsSync(f)) fs.unlinkSync(f); // 同时删除已生成的画像文件
    await ctx.sendText('🚫 已关闭「用户画像」,系统将不再分析你的聊天记录,已生成的画像也已删除。');
    return true;
  }

  // 查看画像(opt-out 自动模式:未拒绝即自动生成/展示)
  if (/^(我的画像|用户画像|画像|我的记忆|记忆|了解我|我是谁)$/.test(text)) {
    if (Number(consent) === 0) {
      await ctx.sendText('🚫 你已关闭用户画像。回复「同意画像」可重新开启。');
      return true;
    }
    const file = profilePath(botId, peerId);
    if (!fs.existsSync(file)) {
      await ctx.sendText('⏳ 正在根据你的聊天记录生成画像,请稍候…');
      const ok = await generateProfile(botId, peerId);
      if (!ok) {
        await ctx.sendText('📝 目前你的聊天记录还不足以生成画像,多聊几句后系统会在后台自动生成,届时再发送「我的画像」查看。');
        return true;
      }
    }
    const content = fs.readFileSync(profilePath(botId, peerId), 'utf8');
    if (content.length > 4000) {
      await ctx.sendText(content.slice(0, 3900) + '\n…(内容较长,可发送「导出画像」获取完整文件)');
    } else {
      await ctx.sendText(content);
    }
    return true;
  }

  // 导出画像文件
  if (text === '导出画像' || text === '导出记忆') {
    if (Number(consent) !== 1) { await ctx.sendText('尚未开启或未生成画像。'); return true; }
    const file = profilePath(botId, peerId);
    if (!fs.existsSync(file)) { await ctx.sendText('画像尚未生成,先发送「我的画像」。'); return true; }
    try { await ctx.sendMedia(file, 'file', '用户画像.md'); }
    catch (e) { await ctx.sendText('导出失败:' + e.message); }
    return true;
  }

  // 删除画像数据
  if (text === '删除画像' || text === '清空画像' || text === '删除记忆' || text === '清空记忆') {
    const file = profilePath(botId, peerId);
    if (fs.existsSync(file)) fs.unlinkSync(file);
    await setLastProfileAt(botId, peerId, 0);
    await ctx.sendText('🗑️ 已删除你的画像数据(聊天记录本身不受影响)。');
    return true;
  }

  // 触发词且未拒绝 → 告知画像已自动生成,可查看/拒绝
  if (Number(consent) !== 0 && /(画像|用户画像|分析我|分析用户|记忆|了解我|我是谁)/.test(text)) {
    await ctx.sendText('🤖 我已在后台自动根据你的聊天记录生成「用户画像」并用于优化回复。\n发送「我的画像」查看,或「拒绝画像」关闭。');
    return true;
  }

  return false;
}

/**
 * 消息入口(由 lib/plugins.onMessage 调用)。
 * 仅当命中本插件指令时返回 true(已处理)。
 */
async function onMessage(msg, ctx) {
  try {
    const bot = (ctx && ctx.bot) || null;
    return await handleCommand(bot, msg, ctx);
  } catch (err) {
    console.error('[memory] 处理异常:', err.message);
    return false;
  }
}

/** 写全局开关(是否启用用户画像功能,全局统一,不区分 bot) */
async function setEnabled(val) {
  const v = val ? '1' : '0';
  await db.exec(
    'INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=?',
    ['memory_enabled', v, v]
  );
}

/** 取某用户最近一次入站聊天时间(秒) */
async function getLastChatAt(botId, peerId) {
  const np = normalizePeer(peerId);
  const r = await db.row(
    `SELECT MAX(created_at) m FROM messages WHERE bot_id=? AND (${peerMatch(np)}) AND direction='in'`,
    [botId, ...peerMatchParams(np)]
  );
  return r && r.m ? Number(r.m) : 0;
}

/** 读取某用户画像文件内容(不存在返回空串) */
function getProfile(botId, peerId) {
  const f = profilePath(botId, peerId);
  return fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
}

/** 保存/编辑某用户画像文件 */
function saveProfile(botId, peerId, content) {
  fs.writeFileSync(profilePath(botId, peerId), content || '');
}

/** 删除某用户画像(文件 + 清除生成时间;聊天记录不受影响) */
async function deleteProfile(botId, peerId) {
  const f = profilePath(botId, peerId);
  if (fs.existsSync(f)) fs.unlinkSync(f);
  await setLastProfileAt(botId, peerId, 0);
}

/** 列出某 bot 下所有已有画像文件的用户(按规范 peer 去重) */
async function listProfiles(botId) {
  if (!fs.existsSync(MEM_DIR)) return [];
  const prefix = `profile_${botId}_`;
  const files = fs.readdirSync(MEM_DIR).filter((f) => f.startsWith(prefix) && f.endsWith('.md'));
  // 优先规范命名文件(raw==normalized),残留变体靠 seen 去重跳过
  const sorted = files.slice().sort((a, b) => {
    const na = normalizePeer(a.slice(prefix.length, -3));
    const nb = normalizePeer(b.slice(prefix.length, -3));
    return (na === a.slice(prefix.length, -3) ? 0 : 1) - (nb === b.slice(prefix.length, -3) ? 0 : 1);
  });
  const seen = new Set();
  const list = [];
  for (const f of sorted) {
    const peerId = f.slice(prefix.length, -3); // 去掉 .md
    const np = normalizePeer(peerId);
    if (seen.has(np)) continue; // 同一规范用户只列一条
    seen.add(np);
    const full = path.join(MEM_DIR, f);
    const content = fs.readFileSync(full, 'utf8');
    let sampleCount = 0;
    const sm = content.match(/样本:(\d+)/);
    if (sm) sampleCount = parseInt(sm[1], 10);
    const consent = await getConsent(botId, np);
    const lastChat = await getLastChatAt(botId, np);
    list.push({
      bot_id: String(botId),
      peer_id: np,
      generated_at: Math.floor(fs.statSync(full).mtimeMs / 1000),
      last_chat_at: lastChat,
      sample_count: sampleCount,
      consent: consent === null || consent === undefined ? null : Number(consent),
      size: content.length,
    });
  }
  list.sort((a, b) => (b.generated_at || 0) - (a.generated_at || 0));
  return list;
}

async function listUsers(botId) {
  const chatUsers = await listChatUsers(botId);
  const profiles = await listProfiles(botId);
  const profMap = {};
  for (const p of profiles) profMap[p.peer_id] = p;
  const seen = new Set();
  const list = [];
  for (const c of chatUsers) {
    seen.add(c.peer_id);
    const p = profMap[c.peer_id];
    const lastChat = p ? p.last_chat_at : await getLastChatAt(botId, c.peer_id);
    list.push({
      bot_id: String(botId),
      peer_id: c.peer_id,
      consent: Number(c.consent) < 0 ? null : Number(c.consent),
      has_profile: !!p,
      generated_at: p ? p.generated_at : 0,
      sample_count: p ? p.sample_count : 0,
      last_chat_at: lastChat,
      size: p ? p.size : 0,
    });
  }
  for (const p of profiles) {
    if (seen.has(p.peer_id)) continue;
    list.push({ ...p, has_profile: true });
  }
  list.sort((a, b) => (b.generated_at || b.last_chat_at || 0) - (a.generated_at || a.last_chat_at || 0));
  return list;
}

/**
 * 智能助手工具执行(由 smart.executeTool 的 default 分支分发)。
 * 提供 save_user_fact / recall_user_facts 两个记忆工具。
 * @param {string} name 工具名
 * @param {object} args 参数
 * @param {object} ctx 含 ctx.bot.id 与 ctx.msg.peer_id
 */
async function handleAiTool(name, args, ctx) {
  try {
    const botId = ctx && ctx.bot && ctx.bot.id;
    const peerId = normalizePeer(ctx && ctx.msg && ctx.msg.peer_id);
    if (!botId || !peerId) return '缺少上下文,无法操作记忆。';
    if (name === 'save_user_fact') {
      const key = (args && args.key != null ? args.key : '').toString().trim();
      const value = (args && args.value != null ? args.value : '').toString().trim();
      if (!key || !value) return '缺少 key 或 value,无法保存。';
      await saveFact(botId, peerId, key, value);
      return `[OK] 已记住:${key} = ${value}`;
    }
    if (name === 'recall_user_facts') {
      const rows = await searchFacts(botId, peerId, args && args.query);
      if (!rows.length) return '暂时还没有记住关于你的任何事实。';
      return '[用户记忆] 已记住的关于你的事实:\n' + rows.map(r => `- ${r.fkey}: ${r.fvalue}`).join('\n');
    }
    return '';
  } catch (e) {
    console.error('[memory] 工具执行失败', name, e.message);
    return '记忆工具出错:' + e.message;
  }
}

module.exports = {
  meta,
  onMessage,
  getCombinedPrompt,
  handleAiTool,
  handleCommand,
  generateProfile,
  generateAllProfiles,
  listChatUsers,
  listUsers,
  isEnabled,
  setEnabled,
  getConsent,
  setConsent,
  profilePath,
  getProfile,
  saveProfile,
  deleteProfile,
  listProfiles,
};