码桶
发现社区成员的开源项目
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,
};