Commit 412167f829de1fe0f873565ae16c53593937071c

Authored by yibo
1 parent 5fbe9a6d

feat: 白名单门禁 + owner 授权流程

需求:不再对任何人自动回复,只有白名单的群/私聊才自动回;
非白名单的发信人,先转给主人(owner)请示是否授权。

实现:
- WhitelistStore(持久化 .whitelist.json):allowed Set + pending dict
- CONFIG.ownerWxid / ownerDisplayName(默认 yibooo / 火星人,env 可覆盖)
- owner 永远在白名单(constructor 自动加)

handleMessage 门禁逻辑(replyType 判定后):
- 发送者身份 senderWxid:私聊=talker,群聊=content 前缀里的 senderWxid
- owner 自己发的(私聊/群@):先 parseApprovalCommand 看是不是授权指令,
  否则放行
- 非 owner 且 talker 不在白名单:requestAuthorization 转给 owner,不回复
- 白名单内:generateAndSendReply(原流程,抽成独立方法)

授权流程:
- requestAuthorization: 给 owner 发「【授权请求】X 发来:...,回复『同意』授权」,
  记入 pending(同一 talker 不重复请示)
- parseApprovalCommand: 解析「同意/授权/批准/approve」「拒绝/忽略/deny」+ 可选目标名
- handleApproval: 按 wxid/昵称/senderName 匹配 pending(没指定取最近一个),
  同意→加白名单+回复 TA 刚才那条 pending 消息;拒绝→移除 pending
- sendPlainTo: 给 owner 发纯文本(不走 hermes),复用 A11y broadcast

重构:把回复生成从 handleMessage 抽成 generateAndSendReply({talker,content,
msgId,replyType,senderName,raw}),授权通过后可复用(回复 pending 消息时 msgId=null)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/services/wechat/auto-reply.js
... ... @@ -38,6 +38,14 @@ const CONFIG = {
38 38 myWxid: process.env.MY_WXID || '',
39 39 myNickname: process.env.MY_NICKNAME || '',
40 40  
  41 + // 主人(授权者):非白名单消息会转给 TA 请示是否授权
  42 + // ownerWxid 是机器人主人的 wxid(如 yibooo),ownerDisplayName 是 A11y 搜索用的昵称
  43 + ownerWxid: process.env.OWNER_WXID || 'yibooo',
  44 + ownerDisplayName: process.env.OWNER_NICKNAME || '火星人',
  45 +
  46 + // 白名单文件:记录已授权的 wxid / 群 + 待授权的 pending 请求
  47 + whitelistFile: path.join(__dirname, '.whitelist.json'),
  48 +
41 49 // hermes-agent API (OpenAI-compatible, localhost:8642)
42 50 hermesHost: process.env.HERMES_HOST || '127.0.0.1',
43 51 hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642,
... ... @@ -129,6 +137,64 @@ class ReplyState {
129 137 }
130 138  
131 139 // ═══════════════════════════════════════════════════════════════
  140 +// 白名单 + 授权 pending(持久化到 .whitelist.json)
  141 +// ═══════════════════════════════════════════════════════════════
  142 +
  143 +class WhitelistStore {
  144 + constructor(filePath) {
  145 + this.filePath = filePath;
  146 + this.allowed = new Set(); // 已授权的 wxid / 群 chatroom id
  147 + this.pending = {}; // talker -> { senderName, content, displayName, ts }
  148 + this.load();
  149 + }
  150 +
  151 + load() {
  152 + try {
  153 + if (fs.existsSync(this.filePath)) {
  154 + const data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
  155 + this.allowed = new Set(data.allowed || []);
  156 + this.pending = data.pending || {};
  157 + }
  158 + } catch (e) {
  159 + log('[WHITELIST] load failed:', e.message);
  160 + }
  161 + }
  162 +
  163 + save() {
  164 + try {
  165 + fs.writeFileSync(this.filePath, JSON.stringify({
  166 + allowed: Array.from(this.allowed),
  167 + pending: this.pending,
  168 + }, null, 2), 'utf-8');
  169 + } catch (e) {
  170 + log('[WHITELIST] save failed:', e.message);
  171 + }
  172 + }
  173 +
  174 + isAllowed(talker) { return this.allowed.has(talker); }
  175 +
  176 + allow(talker) {
  177 + this.allowed.add(talker);
  178 + delete this.pending[talker];
  179 + this.save();
  180 + }
  181 +
  182 + addPending(talker, info) {
  183 + this.pending[talker] = info;
  184 + this.save();
  185 + }
  186 +
  187 + hasPending(talker) { return Object.prototype.hasOwnProperty.call(this.pending, talker); }
  188 +
  189 + removePending(talker) {
  190 + delete this.pending[talker];
  191 + this.save();
  192 + }
  193 +
  194 + pendingTalkers() { return Object.keys(this.pending); }
  195 +}
  196 +
  197 +// ═══════════════════════════════════════════════════════════════
132 198 // 日志
133 199 // ═══════════════════════════════════════════════════════════════
134 200  
... ... @@ -513,6 +579,25 @@ function escapeRegex(str) {
513 579 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
514 580 }
515 581  
  582 +/**
  583 + * 解析 owner 的授权指令。返回 { approve, target } 或 null(不是指令)。
  584 + * 例:"同意" → {approve:true, target:''}
  585 + * "同意 火星人" → {approve:true, target:'火星人'}
  586 + * "拒绝" → {approve:false, target:''}
  587 + */
  588 +function parseApprovalCommand(content) {
  589 + if (!content) return null;
  590 + // 群消息前缀剥离(owner 私聊一般没有,保险起见)
  591 + let t = content;
  592 + const ci = t.indexOf(':\n');
  593 + if (ci > 0 && ci < 30) t = t.substring(ci + 2);
  594 + t = t.trim();
  595 + const m = t.match(/^(同意|授权|批准|approve|allow|yes|拒绝|忽略|不同意|deny|no)\s*(.*)$/i);
  596 + if (!m) return null;
  597 + const approve = /同意|授权|批准|approve|allow|yes/i.test(m[1]);
  598 + return { approve, target: (m[2] || '').trim() };
  599 +}
  600 +
516 601 // ═══════════════════════════════════════════════════════════════
517 602 // 屏幕状态检测与微信唤醒(息屏专用)
518 603 // ═══════════════════════════════════════════════════════════════
... ... @@ -681,6 +766,9 @@ class AutoReplyBot {
681 766 // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定)
682 767 this.tracker = new AndroidWechatTrackerSQLCipher();
683 768 this.state = new ReplyState(CONFIG.stateFile);
  769 + this.whitelist = new WhitelistStore(CONFIG.whitelistFile);
  770 + // owner 永远在白名单里
  771 + if (CONFIG.ownerWxid) this.whitelist.allowed.add(CONFIG.ownerWxid);
684 772 this.replyGenerator = replyGenerator;
685 773 this.running = false;
686 774 }
... ... @@ -716,8 +804,33 @@ class AutoReplyBot {
716 804  
717 805 if (!replyType) return;
718 806  
  807 + // ═══ 白名单门禁 + owner 授权流程 ═══
  808 + // 发送者身份:私聊就是 talker,群聊是 content 前缀里的 senderWxid(即 senderName 变量)
  809 + const senderWxid = isGroupChat(talker) ? senderName : talker;
  810 + const isFromOwner = (senderWxid === CONFIG.ownerWxid) || (talker === CONFIG.ownerWxid);
  811 +
  812 + if (isFromOwner) {
  813 + // owner 自己发的(私聊 / 群里 @):先看是不是授权指令
  814 + const cmd = parseApprovalCommand(content);
  815 + if (cmd) {
  816 + this.state.markReplied(msgId, talker);
  817 + await this.handleApproval(cmd);
  818 + return;
  819 + }
  820 + // 否则放行(owner 永远白名单)
  821 + } else if (!this.whitelist.isAllowed(talker)) {
  822 + // 群 / 私聊不在白名单 → 请示 owner,不直接回复
  823 + this.state.markReplied(msgId, talker);
  824 + await this.requestAuthorization(talker, senderName, content);
  825 + return;
  826 + }
  827 +
719 828 log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`);
  829 + await this.generateAndSendReply({ talker, content, msgId, replyType, senderName, raw: msg.raw });
  830 + }
720 831  
  832 + // 生成并发送回复(白名单通过后 / 授权后调用)
  833 + async generateAndSendReply({ talker, content, msgId, replyType, senderName, raw }) {
721 834 // 发送前确保微信在前台(息屏时通知监听会先唤醒)
722 835 const fg = await isWechatForeground();
723 836 if (!fg) {
... ... @@ -800,17 +913,109 @@ class AutoReplyBot {
800 913 try {
801 914 const sessionId = `wx-${talker}`;
802 915 const messages = buildHermesMessages({
803   - type: replyType, talker, displayName, content, senderName, history, raw: msg.raw,
  916 + type: replyType, talker, displayName, content, senderName, history, raw,
804 917 });
805 918 log('[HERMES] Streaming for', talker, 'turns=' + messages.length);
806 919 const result = await callHermesRunStream(messages, sessionId, onUtterance);
807 920 log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {}));
808   - this.state.markReplied(msgId, talker);
  921 + if (msgId != null) this.state.markReplied(msgId, talker);
809 922 } catch (e) {
810 923 log('[STREAM ERROR]', e.message);
811 924 }
812 925 }
813 926  
  927 + // 给 owner 发一条纯文本消息(授权请求 / 确认),不走 hermes
  928 + async sendPlainTo(toUser, displayName, text) {
  929 + const fg = await isWechatForeground();
  930 + if (!fg) {
  931 + await wakeWechat();
  932 + await ensureWechatForeground();
  933 + await sleep(2000);
  934 + }
  935 + try {
  936 + const r = await sendTextMessageBroadcast(toUser, text, { toUserDisplayName: displayName });
  937 + log('[AUTH SEND]', toUser, r.ok ? 'OK' : r.error);
  938 + await sleep(10000); // 等 A11y 跑完
  939 + } catch (e) {
  940 + log('[AUTH SEND ERROR]', e.message);
  941 + }
  942 + }
  943 +
  944 + // 非白名单消息 → 请示 owner
  945 + async requestAuthorization(talker, senderName, content) {
  946 + if (this.whitelist.hasPending(talker)) {
  947 + log('[AUTH] Already pending for', talker, '— skip duplicate ask');
  948 + return;
  949 + }
  950 + let dn = talker;
  951 + try { dn = await this.tracker.getContactNickname(talker); } catch (_) {}
  952 +
  953 + // 群消息 content 形如 "senderWxid:\n实际内容",剥掉前缀
  954 + let cleanContent = content;
  955 + const ci = cleanContent.indexOf(':\n');
  956 + if (ci > 0) cleanContent = cleanContent.substring(ci + 2);
  957 + cleanContent = cleanContent.replace(/<[^>]+>/g, '').trim().substring(0, 100);
  958 +
  959 + const who = isGroupChat(talker)
  960 + ? `群「${dn}」里的 ${senderName || '某人'}`
  961 + : `${dn}`;
  962 +
  963 + this.whitelist.addPending(talker, {
  964 + senderName, content, displayName: dn, ts: Date.now(),
  965 + });
  966 + log('[AUTH] Pending authorization for', talker, '(' + dn + ')');
  967 +
  968 + const ask = `【授权请求】${who} 给我发消息了:\n「${cleanContent}」\n\n`
  969 + + `回复「同意」我就自动回复 TA;回复「拒绝」忽略。`
  970 + + `(多个待授权时用「同意 ${dn}」指定)`;
  971 + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, ask);
  972 + }
  973 +
  974 + // 处理 owner 的授权指令
  975 + async handleApproval(cmd) {
  976 + const { approve, target } = cmd;
  977 + const pendings = this.whitelist.pendingTalkers();
  978 + if (pendings.length === 0) {
  979 + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, '当前没有待授权的请求。');
  980 + return;
  981 + }
  982 +
  983 + // 选定目标:指定了就按 wxid / 昵称 / senderName 匹配,没指定取最近一个
  984 + let talker = null;
  985 + if (target) {
  986 + talker = pendings.find(t => {
  987 + const p = this.whitelist.pending[t];
  988 + return t === target
  989 + || p.displayName === target
  990 + || p.senderName === target
  991 + || (p.displayName && p.displayName.includes(target));
  992 + });
  993 + }
  994 + if (!talker) talker = pendings[pendings.length - 1];
  995 +
  996 + const p = this.whitelist.pending[talker];
  997 + if (approve) {
  998 + this.whitelist.allow(talker);
  999 + log('[AUTH] Approved', talker, '(' + p.displayName + ')');
  1000 + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName,
  1001 + `已授权「${p.displayName}」,正在回复 TA 刚才的消息,之后也会自动回复。`);
  1002 + // 回复 TA 刚才那条 pending 消息
  1003 + let replyType = 'private';
  1004 + let senderName = p.senderName || '';
  1005 + if (isGroupChat(talker)) {
  1006 + replyType = isAtMe(p.content, CONFIG.myNickname) ? 'group-at' : 'group-quote';
  1007 + }
  1008 + await this.generateAndSendReply({
  1009 + talker, content: p.content, msgId: null, replyType, senderName, raw: '',
  1010 + });
  1011 + } else {
  1012 + this.whitelist.removePending(talker);
  1013 + log('[AUTH] Denied', talker, '(' + p.displayName + ')');
  1014 + await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName,
  1015 + `已忽略「${p.displayName}」的请求。`);
  1016 + }
  1017 + }
  1018 +
814 1019 async start() {
815 1020 if (this.running) return;
816 1021 this.running = true;
... ... @@ -825,7 +1030,11 @@ class AutoReplyBot {
825 1030 replyQuoteInGroup: CONFIG.replyQuoteInGroup,
826 1031 historyContextSize: CONFIG.historyContextSize,
827 1032 notificationEnabled: CONFIG.notificationEnabled,
  1033 + ownerWxid: CONFIG.ownerWxid,
  1034 + ownerDisplayName: CONFIG.ownerDisplayName,
828 1035 }));
  1036 + log('[WHITELIST] allowed:', JSON.stringify(Array.from(this.whitelist.allowed)),
  1037 + 'pending:', JSON.stringify(this.whitelist.pendingTalkers()));
829 1038  
830 1039 // A11y service 健康检查 — 发送 broadcast 必须依赖它
831 1040 const a11y = await checkAccessibilityServiceEnabled();
... ...