diff --git a/server/services/wechat/auto-reply.js b/server/services/wechat/auto-reply.js index a29b904..40c1b9a 100644 --- a/server/services/wechat/auto-reply.js +++ b/server/services/wechat/auto-reply.js @@ -38,6 +38,14 @@ const CONFIG = { myWxid: process.env.MY_WXID || '', myNickname: process.env.MY_NICKNAME || '', + // 主人(授权者):非白名单消息会转给 TA 请示是否授权 + // ownerWxid 是机器人主人的 wxid(如 yibooo),ownerDisplayName 是 A11y 搜索用的昵称 + ownerWxid: process.env.OWNER_WXID || 'yibooo', + ownerDisplayName: process.env.OWNER_NICKNAME || '火星人', + + // 白名单文件:记录已授权的 wxid / 群 + 待授权的 pending 请求 + whitelistFile: path.join(__dirname, '.whitelist.json'), + // hermes-agent API (OpenAI-compatible, localhost:8642) hermesHost: process.env.HERMES_HOST || '127.0.0.1', hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642, @@ -129,6 +137,64 @@ class ReplyState { } // ═══════════════════════════════════════════════════════════════ +// 白名单 + 授权 pending(持久化到 .whitelist.json) +// ═══════════════════════════════════════════════════════════════ + +class WhitelistStore { + constructor(filePath) { + this.filePath = filePath; + this.allowed = new Set(); // 已授权的 wxid / 群 chatroom id + this.pending = {}; // talker -> { senderName, content, displayName, ts } + this.load(); + } + + load() { + try { + if (fs.existsSync(this.filePath)) { + const data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')); + this.allowed = new Set(data.allowed || []); + this.pending = data.pending || {}; + } + } catch (e) { + log('[WHITELIST] load failed:', e.message); + } + } + + save() { + try { + fs.writeFileSync(this.filePath, JSON.stringify({ + allowed: Array.from(this.allowed), + pending: this.pending, + }, null, 2), 'utf-8'); + } catch (e) { + log('[WHITELIST] save failed:', e.message); + } + } + + isAllowed(talker) { return this.allowed.has(talker); } + + allow(talker) { + this.allowed.add(talker); + delete this.pending[talker]; + this.save(); + } + + addPending(talker, info) { + this.pending[talker] = info; + this.save(); + } + + hasPending(talker) { return Object.prototype.hasOwnProperty.call(this.pending, talker); } + + removePending(talker) { + delete this.pending[talker]; + this.save(); + } + + pendingTalkers() { return Object.keys(this.pending); } +} + +// ═══════════════════════════════════════════════════════════════ // 日志 // ═══════════════════════════════════════════════════════════════ @@ -513,6 +579,25 @@ function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * 解析 owner 的授权指令。返回 { approve, target } 或 null(不是指令)。 + * 例:"同意" → {approve:true, target:''} + * "同意 火星人" → {approve:true, target:'火星人'} + * "拒绝" → {approve:false, target:''} + */ +function parseApprovalCommand(content) { + if (!content) return null; + // 群消息前缀剥离(owner 私聊一般没有,保险起见) + let t = content; + const ci = t.indexOf(':\n'); + if (ci > 0 && ci < 30) t = t.substring(ci + 2); + t = t.trim(); + const m = t.match(/^(同意|授权|批准|approve|allow|yes|拒绝|忽略|不同意|deny|no)\s*(.*)$/i); + if (!m) return null; + const approve = /同意|授权|批准|approve|allow|yes/i.test(m[1]); + return { approve, target: (m[2] || '').trim() }; +} + // ═══════════════════════════════════════════════════════════════ // 屏幕状态检测与微信唤醒(息屏专用) // ═══════════════════════════════════════════════════════════════ @@ -681,6 +766,9 @@ class AutoReplyBot { // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定) this.tracker = new AndroidWechatTrackerSQLCipher(); this.state = new ReplyState(CONFIG.stateFile); + this.whitelist = new WhitelistStore(CONFIG.whitelistFile); + // owner 永远在白名单里 + if (CONFIG.ownerWxid) this.whitelist.allowed.add(CONFIG.ownerWxid); this.replyGenerator = replyGenerator; this.running = false; } @@ -716,8 +804,33 @@ class AutoReplyBot { if (!replyType) return; + // ═══ 白名单门禁 + owner 授权流程 ═══ + // 发送者身份:私聊就是 talker,群聊是 content 前缀里的 senderWxid(即 senderName 变量) + const senderWxid = isGroupChat(talker) ? senderName : talker; + const isFromOwner = (senderWxid === CONFIG.ownerWxid) || (talker === CONFIG.ownerWxid); + + if (isFromOwner) { + // owner 自己发的(私聊 / 群里 @):先看是不是授权指令 + const cmd = parseApprovalCommand(content); + if (cmd) { + this.state.markReplied(msgId, talker); + await this.handleApproval(cmd); + return; + } + // 否则放行(owner 永远白名单) + } else if (!this.whitelist.isAllowed(talker)) { + // 群 / 私聊不在白名单 → 请示 owner,不直接回复 + this.state.markReplied(msgId, talker); + await this.requestAuthorization(talker, senderName, content); + return; + } + log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); + await this.generateAndSendReply({ talker, content, msgId, replyType, senderName, raw: msg.raw }); + } + // 生成并发送回复(白名单通过后 / 授权后调用) + async generateAndSendReply({ talker, content, msgId, replyType, senderName, raw }) { // 发送前确保微信在前台(息屏时通知监听会先唤醒) const fg = await isWechatForeground(); if (!fg) { @@ -800,17 +913,109 @@ class AutoReplyBot { try { const sessionId = `wx-${talker}`; const messages = buildHermesMessages({ - type: replyType, talker, displayName, content, senderName, history, raw: msg.raw, + type: replyType, talker, displayName, content, senderName, history, raw, }); log('[HERMES] Streaming for', talker, 'turns=' + messages.length); const result = await callHermesRunStream(messages, sessionId, onUtterance); log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {})); - this.state.markReplied(msgId, talker); + if (msgId != null) this.state.markReplied(msgId, talker); } catch (e) { log('[STREAM ERROR]', e.message); } } + // 给 owner 发一条纯文本消息(授权请求 / 确认),不走 hermes + async sendPlainTo(toUser, displayName, text) { + const fg = await isWechatForeground(); + if (!fg) { + await wakeWechat(); + await ensureWechatForeground(); + await sleep(2000); + } + try { + const r = await sendTextMessageBroadcast(toUser, text, { toUserDisplayName: displayName }); + log('[AUTH SEND]', toUser, r.ok ? 'OK' : r.error); + await sleep(10000); // 等 A11y 跑完 + } catch (e) { + log('[AUTH SEND ERROR]', e.message); + } + } + + // 非白名单消息 → 请示 owner + async requestAuthorization(talker, senderName, content) { + if (this.whitelist.hasPending(talker)) { + log('[AUTH] Already pending for', talker, '— skip duplicate ask'); + return; + } + let dn = talker; + try { dn = await this.tracker.getContactNickname(talker); } catch (_) {} + + // 群消息 content 形如 "senderWxid:\n实际内容",剥掉前缀 + let cleanContent = content; + const ci = cleanContent.indexOf(':\n'); + if (ci > 0) cleanContent = cleanContent.substring(ci + 2); + cleanContent = cleanContent.replace(/<[^>]+>/g, '').trim().substring(0, 100); + + const who = isGroupChat(talker) + ? `群「${dn}」里的 ${senderName || '某人'}` + : `${dn}`; + + this.whitelist.addPending(talker, { + senderName, content, displayName: dn, ts: Date.now(), + }); + log('[AUTH] Pending authorization for', talker, '(' + dn + ')'); + + const ask = `【授权请求】${who} 给我发消息了:\n「${cleanContent}」\n\n` + + `回复「同意」我就自动回复 TA;回复「拒绝」忽略。` + + `(多个待授权时用「同意 ${dn}」指定)`; + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, ask); + } + + // 处理 owner 的授权指令 + async handleApproval(cmd) { + const { approve, target } = cmd; + const pendings = this.whitelist.pendingTalkers(); + if (pendings.length === 0) { + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, '当前没有待授权的请求。'); + return; + } + + // 选定目标:指定了就按 wxid / 昵称 / senderName 匹配,没指定取最近一个 + let talker = null; + if (target) { + talker = pendings.find(t => { + const p = this.whitelist.pending[t]; + return t === target + || p.displayName === target + || p.senderName === target + || (p.displayName && p.displayName.includes(target)); + }); + } + if (!talker) talker = pendings[pendings.length - 1]; + + const p = this.whitelist.pending[talker]; + if (approve) { + this.whitelist.allow(talker); + log('[AUTH] Approved', talker, '(' + p.displayName + ')'); + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, + `已授权「${p.displayName}」,正在回复 TA 刚才的消息,之后也会自动回复。`); + // 回复 TA 刚才那条 pending 消息 + let replyType = 'private'; + let senderName = p.senderName || ''; + if (isGroupChat(talker)) { + replyType = isAtMe(p.content, CONFIG.myNickname) ? 'group-at' : 'group-quote'; + } + await this.generateAndSendReply({ + talker, content: p.content, msgId: null, replyType, senderName, raw: '', + }); + } else { + this.whitelist.removePending(talker); + log('[AUTH] Denied', talker, '(' + p.displayName + ')'); + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, + `已忽略「${p.displayName}」的请求。`); + } + } + async start() { if (this.running) return; this.running = true; @@ -825,7 +1030,11 @@ class AutoReplyBot { replyQuoteInGroup: CONFIG.replyQuoteInGroup, historyContextSize: CONFIG.historyContextSize, notificationEnabled: CONFIG.notificationEnabled, + ownerWxid: CONFIG.ownerWxid, + ownerDisplayName: CONFIG.ownerDisplayName, })); + log('[WHITELIST] allowed:', JSON.stringify(Array.from(this.whitelist.allowed)), + 'pending:', JSON.stringify(this.whitelist.pendingTalkers())); // A11y service 健康检查 — 发送 broadcast 必须依赖它 const a11y = await checkAccessibilityServiceEnabled();