Commit 412167f829de1fe0f873565ae16c53593937071c
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>
Showing
1 changed file
with
211 additions
and
2 deletions
server/services/wechat/auto-reply.js
| @@ -38,6 +38,14 @@ const CONFIG = { | @@ -38,6 +38,14 @@ const CONFIG = { | ||
| 38 | myWxid: process.env.MY_WXID || '', | 38 | myWxid: process.env.MY_WXID || '', |
| 39 | myNickname: process.env.MY_NICKNAME || '', | 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 | // hermes-agent API (OpenAI-compatible, localhost:8642) | 49 | // hermes-agent API (OpenAI-compatible, localhost:8642) |
| 42 | hermesHost: process.env.HERMES_HOST || '127.0.0.1', | 50 | hermesHost: process.env.HERMES_HOST || '127.0.0.1', |
| 43 | hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642, | 51 | hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642, |
| @@ -129,6 +137,64 @@ class ReplyState { | @@ -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,6 +579,25 @@ function escapeRegex(str) { | ||
| 513 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | 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,6 +766,9 @@ class AutoReplyBot { | ||
| 681 | // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定) | 766 | // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定) |
| 682 | this.tracker = new AndroidWechatTrackerSQLCipher(); | 767 | this.tracker = new AndroidWechatTrackerSQLCipher(); |
| 683 | this.state = new ReplyState(CONFIG.stateFile); | 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 | this.replyGenerator = replyGenerator; | 772 | this.replyGenerator = replyGenerator; |
| 685 | this.running = false; | 773 | this.running = false; |
| 686 | } | 774 | } |
| @@ -716,8 +804,33 @@ class AutoReplyBot { | @@ -716,8 +804,33 @@ class AutoReplyBot { | ||
| 716 | 804 | ||
| 717 | if (!replyType) return; | 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 | log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); | 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 | const fg = await isWechatForeground(); | 835 | const fg = await isWechatForeground(); |
| 723 | if (!fg) { | 836 | if (!fg) { |
| @@ -800,17 +913,109 @@ class AutoReplyBot { | @@ -800,17 +913,109 @@ class AutoReplyBot { | ||
| 800 | try { | 913 | try { |
| 801 | const sessionId = `wx-${talker}`; | 914 | const sessionId = `wx-${talker}`; |
| 802 | const messages = buildHermesMessages({ | 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 | log('[HERMES] Streaming for', talker, 'turns=' + messages.length); | 918 | log('[HERMES] Streaming for', talker, 'turns=' + messages.length); |
| 806 | const result = await callHermesRunStream(messages, sessionId, onUtterance); | 919 | const result = await callHermesRunStream(messages, sessionId, onUtterance); |
| 807 | log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {})); | 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 | } catch (e) { | 922 | } catch (e) { |
| 810 | log('[STREAM ERROR]', e.message); | 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 | async start() { | 1019 | async start() { |
| 815 | if (this.running) return; | 1020 | if (this.running) return; |
| 816 | this.running = true; | 1021 | this.running = true; |
| @@ -825,7 +1030,11 @@ class AutoReplyBot { | @@ -825,7 +1030,11 @@ class AutoReplyBot { | ||
| 825 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, | 1030 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, |
| 826 | historyContextSize: CONFIG.historyContextSize, | 1031 | historyContextSize: CONFIG.historyContextSize, |
| 827 | notificationEnabled: CONFIG.notificationEnabled, | 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 | // A11y service 健康检查 — 发送 broadcast 必须依赖它 | 1039 | // A11y service 健康检查 — 发送 broadcast 必须依赖它 |
| 831 | const a11y = await checkAccessibilityServiceEnabled(); | 1040 | const a11y = await checkAccessibilityServiceEnabled(); |