@voko/lite 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +32 -0
  2. package/scripts/build-native.js +72 -0
  3. package/src/bankHeadOffices.js +20543 -0
  4. package/src/channels/email.js +35 -0
  5. package/src/channels/feishu.js +31 -0
  6. package/src/channels/qq-email.js +30 -0
  7. package/src/channels/registry.js +279 -0
  8. package/src/channels/telegram.js +28 -0
  9. package/src/channels/voko-email.js +7 -0
  10. package/src/channels/wechat.js +35 -0
  11. package/src/cli.js +120 -0
  12. package/src/context.js +164 -0
  13. package/src/core/access-control-api.js +150 -0
  14. package/src/core/access-control.js +56 -0
  15. package/src/core/agent-registration.js +319 -0
  16. package/src/core/api-signature.js +33 -0
  17. package/src/core/audit.js +133 -0
  18. package/src/core/database.js +1409 -0
  19. package/src/core/did-auth.js +54 -0
  20. package/src/core/hermes-paths.js +57 -0
  21. package/src/core/invitation.js +49 -0
  22. package/src/core/lite-bus.js +16 -0
  23. package/src/core/llm-client.js +1032 -0
  24. package/src/core/messenger.js +456 -0
  25. package/src/core/notifier.js +99 -0
  26. package/src/core/offline-sync.js +150 -0
  27. package/src/core/payment.js +285 -0
  28. package/src/core/publish-agent.js +166 -0
  29. package/src/core/register-capabilities.js +119 -0
  30. package/src/core/search-capabilities.js +136 -0
  31. package/src/core/send-message.js +85 -0
  32. package/src/core/set-agent-status.js +65 -0
  33. package/src/core/update-agent-profile.js +102 -0
  34. package/src/core/worker-manager.js +332 -0
  35. package/src/endpoints.json +21 -0
  36. package/src/index.js +712 -0
  37. package/src/mcp/CLAUDE_TEST.md +82 -0
  38. package/src/mcp/FULL_TEST.md +139 -0
  39. package/src/mcp/TEST.md +124 -0
  40. package/src/mcp/TEST_STEPS.md +75 -0
  41. package/src/mcp/server.js +612 -0
  42. package/src/mcp/tools.js +1367 -0
  43. package/src/mcp/transport/http.js +95 -0
  44. package/src/mcp/transport/stdio.js +20 -0
  45. package/src/preload.js +27 -0
  46. package/src/server/agent-email-api.js +120 -0
  47. package/src/server/agent-manager.js +580 -0
  48. package/src/server/email-handler.js +329 -0
  49. package/src/server/feishu-handler.js +249 -0
  50. package/src/server/hermes-api-client.js +166 -0
  51. package/src/server/hermes-discovery.js +80 -0
  52. package/src/server/hermes-handler.js +287 -0
  53. package/src/server/openclaw-handler-cli.js +131 -0
  54. package/src/server/openclaw-websocket-handler.js +1290 -0
  55. package/src/server/oss.js +186 -0
  56. package/src/server/owner-intervention-notifier.js +320 -0
  57. package/src/server/release-page.html +204 -0
  58. package/src/server/telegram-handler.js +208 -0
  59. package/src/server/voko-email-handler.js +68 -0
  60. package/src/server/wechat-handler.js +439 -0
  61. package/src/workers/agent-worker.js +378 -0
  62. package/src/workers/message-content.js +51 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * messenger.js — 消息处理核心
3
+ *
4
+ * 消息处理中枢:handleAgentMessage / handleAgentReply / forwardToAgent。
5
+ * 纯 Node.js,无 Electron 依赖。通过 options 注入外部依赖。
6
+ */
7
+
8
+ const EventEmitter = require('events');
9
+
10
+ class MessageHandler extends EventEmitter {
11
+ /**
12
+ * @param {object} db - better-sqlite3 实例
13
+ * @param {object} options
14
+ * @param {object} options.databaseAPI - DB 查询封装
15
+ * @param {object} options.agentWorkers - AgentWorkerManager.workers Map
16
+ * @param {object} [options.hermesHandler] - Hermes 处理器(可选,Lite 不传)
17
+ * @param {object} [options.openclawHandler] - OpenClaw 处理器(可选,Lite 不传)
18
+ * @param {object} [options.ac] - access-control 模块(可选)
19
+ * @param {Function} [options.sendSystemMessage] - 发送系统消息函数
20
+ * @param {Function} [options.checkAuditRules] - 审核规则检查函数
21
+ * @param {Function} [options.substitutePromptVariables] - 提示词变量替换函数
22
+ * @param {Function} [options.notifyUI] - UI 通知回调 (eventName, data)
23
+ * @param {Function} [options.notifyTray] - 托盘通知回调
24
+ * @param {Function} [options.enqueueIntervention] - 主人介入入队回调
25
+ * @param {Function} [options.createPendingPayment] - 创建支付订单回调
26
+ * @param {Function} [options.onOwnerInterventionNew] - 有新介入时回调
27
+ */
28
+ constructor(db, options = {}) {
29
+ super();
30
+ this.db = db;
31
+ this.databaseAPI = options.databaseAPI;
32
+ this.agentWorkers = options.agentWorkers;
33
+ this.hermesHandler = options.hermesHandler || null;
34
+ this.openclawHandler = options.openclawHandler || null;
35
+ this.ac = options.ac || null;
36
+ this._sendSystemMessage = options.sendSystemMessage || (() => {});
37
+ this._checkAuditRules = options.checkAuditRules || (() => ({ action: 'allow' }));
38
+ this._substitutePromptVariables = options.substitutePromptVariables || ((p) => p);
39
+ this._notifyUI = options.notifyUI || (() => {});
40
+ this._enqueueIntervention = options.enqueueIntervention || (() => {});
41
+ this._createPendingPayment = options.createPendingPayment || (() => {});
42
+ this._onOwnerInterventionNew = options.onOwnerInterventionNew || (() => {});
43
+
44
+ // 预填充大小写映射(OpenClaw WS)
45
+ this._caseMap = new Map();
46
+ }
47
+
48
+ /** 设置 Hermes 处理器(延迟初始化) */
49
+ setHermesHandler(handler) { this.hermesHandler = handler; }
50
+ /** 设置 OpenClaw 处理器(延迟初始化) */
51
+ setOpenclawHandler(handler) { this.openclawHandler = handler; }
52
+
53
+ // ==========================================
54
+ // 审计:插入被拦截消息
55
+ // ==========================================
56
+ insertBlockedMessage(agentId, visitorId, content, keyword, action, direction, fromUid, ts, originalMsgId) {
57
+ if (!ts) ts = Math.floor(Date.now() / 1000);
58
+ const msgId = originalMsgId || ('blk-' + agentId + '-' + visitorId + '-' + ts + '-' + Math.random().toString(36).substr(2, 4));
59
+ const title = direction === 'inbound' ? '触发入站消息审核' : '触发出站消息审核';
60
+ const isMe = direction === 'inbound' ? 0 : 1;
61
+ const actionLabel = direction === 'inbound'
62
+ ? (action === 'hard_deny' ? '已拒绝,已回复提示语给访客' : '已放行,已转发给 Agent')
63
+ : (action === 'hard_deny' ? '已拒绝,未发送给访客' : '已放行,已发送给访客');
64
+ const jsonContent = JSON.stringify({
65
+ text: content,
66
+ audit: '⚠️ ' + title + '\n命中敏感词: ' + keyword + '\n' + actionLabel,
67
+ keyword: keyword,
68
+ action: action
69
+ });
70
+
71
+ try {
72
+ if (originalMsgId) {
73
+ this.db.prepare('UPDATE messages SET content_type=11, content=? WHERE id=?').run(jsonContent, originalMsgId);
74
+ } else {
75
+ this.db.prepare('INSERT INTO messages (id, from_uid, to_uid, content, channel_id, channel_type, agent_id, timestamp, is_me, status, content_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
76
+ .run(msgId, fromUid, visitorId, jsonContent, visitorId, 1, agentId, ts, isMe, 'sent', 11);
77
+ }
78
+ } catch (e) {
79
+ console.error('[Audit] 写入拦截消息失败:', e.message);
80
+ }
81
+
82
+ try {
83
+ const displayText = '⛔ 消息被拦截';
84
+ const existConv = this.db.prepare('SELECT user_uid FROM conversations WHERE channel_id = ? AND agent_id = ?').get(visitorId, agentId);
85
+ if (existConv) {
86
+ this.db.prepare('UPDATE conversations SET last_message = ?, last_timestamp = ? WHERE user_uid = ? AND channel_id = ?')
87
+ .run(displayText, ts, existConv.user_uid, visitorId);
88
+ } else {
89
+ this.db.prepare('INSERT INTO conversations (user_uid, channel_id, channel_type, name, last_message, last_timestamp, unread_count, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
90
+ .run(fromUid, visitorId, 1, visitorId, displayText, ts, 1, agentId);
91
+ }
92
+ } catch (e) {
93
+ console.error('[Audit] 会话更新失败:', e.message);
94
+ }
95
+
96
+ this._notifyUI('agent-wukongim:message', {
97
+ agentId, fromUid, toUid: visitorId, channelId: visitorId,
98
+ content: jsonContent, messageId: msgId, timestamp: ts, isMe, contentType: 11
99
+ });
100
+ }
101
+
102
+ // ==========================================
103
+ // 消息处理中枢
104
+ // ==========================================
105
+ handleAgentMessage(agentId, data, skipForward = false) {
106
+ const { fromUid, toUid, channelId, content, messageId, timestamp, channelType, contentType,
107
+ messageSeq, clientMsgNo, noPersist, redDot, syncOnce } = data;
108
+
109
+ if (channelId && typeof this.openclawHandler?.setCaseMapEntry === 'function') {
110
+ this.openclawHandler.setCaseMapEntry(agentId, channelId);
111
+ }
112
+
113
+ if (!content || (typeof content === 'string' && content.trim() === '')) {
114
+ console.log(`[消息跳过] agentId=${agentId} content 为空`);
115
+ return;
116
+ }
117
+
118
+ const systemMsg = ['NO_REPLY', 'HEARTBEAT_OK', 'ANNOUNCE_SKIP'];
119
+ if (typeof content === 'string' && systemMsg.includes(content.trim())) {
120
+ console.log(`[消息跳过] agentId=${agentId} 系统消息: ${content.trim()}`);
121
+ return;
122
+ }
123
+
124
+ // 保存到数据库
125
+ try {
126
+ const stmt = this.db.prepare(`
127
+ INSERT INTO messages (id, from_uid, to_uid, content, channel_id, channel_type, agent_id, timestamp, is_me, status, message_seq, client_msg_no, no_persist, red_dot, sync_once, content_type)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ `);
130
+ stmt.run(messageId, fromUid, toUid, typeof content === 'string' ? content : String(content), channelId, channelType || 1, agentId, timestamp, 0, 'received', messageSeq ?? null, clientMsgNo ?? null, noPersist ?? 0, redDot ?? 0, syncOnce ?? 0, data.contentType || 1);
131
+ } catch (e) {
132
+ if (!e.message.includes('UNIQUE constraint')) {
133
+ console.error(`[消息存储] 失败:`, e.message);
134
+ }
135
+ }
136
+
137
+ // 更新会话
138
+ try {
139
+ const exist = this.db.prepare(`SELECT user_uid FROM conversations WHERE user_uid = ? AND channel_id = ?`).get(toUid, channelId);
140
+ if (exist) {
141
+ this.db.prepare(`UPDATE conversations SET last_message = ?, last_timestamp = ?, agent_id = COALESCE(?, agent_id) WHERE user_uid = ? AND channel_id = ?`)
142
+ .run(typeof content === 'string' ? content : String(content), timestamp, agentId, toUid, channelId);
143
+ } else {
144
+ this.db.prepare(`INSERT INTO conversations (user_uid, channel_id, channel_type, name, last_message, last_timestamp, unread_count, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
145
+ .run(toUid, channelId, channelType || 1, fromUid, typeof content === 'string' ? content : String(content), timestamp, 1, agentId);
146
+ }
147
+ } catch (e) {
148
+ console.error(`[会话存储] 失败:`, e.message);
149
+ }
150
+
151
+ // 通知 UI + 系统通知(含提示音)
152
+ console.log('[通知] 收到访客消息, from=' + fromUid + ' agent=' + agentId + ' content="' + (typeof content === 'string' ? content.substring(0, 30) : '') + '"');
153
+ this._notifyUI('agent-wukongim:message', {
154
+ agentId, fromUid, toUid, channelId,
155
+ content: typeof content === 'string' ? content : String(content),
156
+ contentType: data.contentType || 1, messageId, timestamp, isMe: false
157
+ });
158
+
159
+ // 检查发布状态
160
+ const agentStatusRow = this.db.prepare(`SELECT publish_status, owner_email, access_mode FROM agents WHERE agent_id = ?`).get(agentId);
161
+ if (agentStatusRow && agentStatusRow.publish_status !== 'published' && agentStatusRow.publish_status !== 'private') {
162
+ const ownerEmail = agentStatusRow.owner_email || '管理员';
163
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】当前Agent已下架,请联系' + ownerEmail + '。', timestamp);
164
+ return;
165
+ }
166
+
167
+ // 黑白名单检查
168
+ if (agentStatusRow && this.ac) {
169
+ if (this.ac.isBlacklisted(this.db, agentId, fromUid)) {
170
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】您已被加入黑名单,无法继续交流。', timestamp);
171
+ return;
172
+ }
173
+ if (agentStatusRow.access_mode === 'private') {
174
+ const whitelisted = this.ac.isWhitelisted(this.db, agentId, fromUid);
175
+ if (!whitelisted) {
176
+ // 检测消息中是否含邀请码,有则自动通过
177
+ const match = typeof content === 'string' ? content.match(/邀请码[::]\s*([A-Za-z0-9]{6})|invite[::]\s*([A-Za-z0-9]{6})/i) : null;
178
+ const code = match?.[1] || match?.[2];
179
+ if (code) {
180
+ const inviteRow = this.db.prepare(`SELECT * FROM friend_invitations WHERE code=? AND agent_id=?`).get(code, agentId);
181
+ if (inviteRow && !inviteRow.whitelisted) {
182
+ this.ac.addEntry(this.db, { agentId, listType: 'whitelist', visitorId: fromUid, reason: '邀请码自动通过' });
183
+ this.db.prepare(`DELETE FROM friend_invitations WHERE code=? AND agent_id=?`).run(code);
184
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】欢迎!你已通过邀请自动加入白名单。', timestamp);
185
+ console.log(`[邀请] 邀请码 ${code} 已验证,访客 ${fromUid} 已加入白名单`);
186
+ // 继续处理消息,不拦截
187
+ }
188
+ }
189
+ }
190
+ if (!this.ac.isWhitelisted(this.db, agentId, fromUid)) {
191
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】好友申请已收到,请等待确认。', timestamp);
192
+ this._triggerFriendRequestIntervention(agentId, fromUid, typeof content === 'string' ? content : String(content), timestamp);
193
+ return;
194
+ }
195
+ }
196
+ }
197
+
198
+ // 计费检查
199
+ const pricingRow = this.db.prepare('SELECT * FROM agent_pricing WHERE agent_id = ? AND enabled = 1').get(agentId);
200
+ if (pricingRow && pricingRow.pricing_model === 'timed') {
201
+ const conv = this.db.prepare('SELECT session_status, session_expire_at FROM conversations WHERE user_uid = ? AND channel_id = ?').get(toUid, channelId);
202
+ const isBuyCmd = typeof content === 'string' && content.trim() === '购买';
203
+
204
+ if (!conv || !conv.session_status) {
205
+ if (pricingRow.trial_minutes > 0 && !isBuyCmd) {
206
+ const paidCount = this.db.prepare('SELECT COUNT(*) as c FROM payment_orders WHERE agent_id = ? AND visitor_id = ? AND status = ?').get(agentId, fromUid, 'paid');
207
+ if (paidCount.c === 0) {
208
+ const expireAt = Date.now() + pricingRow.trial_minutes * 60 * 1000;
209
+ this.db.prepare('UPDATE conversations SET session_status=?, session_expire_at=? WHERE user_uid=? AND channel_id=?').run('active', expireAt, toUid, channelId);
210
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】欢迎使用本Agent!首次可免费试用 ' + pricingRow.trial_minutes + ' 分钟。试用结束后需付费,资费:' + pricingRow.price + '元/' + pricingRow.duration_minutes + '分钟。回复"购买"查看支付方式。', timestamp);
211
+ } else {
212
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】欢迎回来!该 Agent 为付费服务,资费:' + pricingRow.price + '元/' + pricingRow.duration_minutes + '分钟。回复"购买"查看支付方式。', timestamp);
213
+ return;
214
+ }
215
+ } else {
216
+ if (isBuyCmd) {
217
+ this._createPendingPayment(agentId, fromUid, toUid, pricingRow, timestamp);
218
+ } else {
219
+ const trialPart = pricingRow.trial_minutes > 0
220
+ ? '【系统消息】欢迎使用本Agent!首次可免费试用 ' + pricingRow.trial_minutes + '分钟。试用结束后需付费,资费:'
221
+ : '【系统消息】本Agent需付费使用,资费:';
222
+ this._sendSystemMessage(agentId, fromUid, trialPart + pricingRow.price + '元/' + pricingRow.duration_minutes + '分钟。回复"购买"查看支付方式。', timestamp);
223
+ }
224
+ return;
225
+ }
226
+ } else if (conv.session_status === 'active') {
227
+ if (conv.session_expire_at && conv.session_expire_at > Date.now()) {
228
+ if (conv.session_expire_at - Date.now() < 60000) {
229
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】⏰ 您的服务即将到期(剩余不到 1 分钟),如需继续使用请回复"购买"。', timestamp);
230
+ }
231
+ } else {
232
+ this.db.prepare('UPDATE conversations SET session_status=? WHERE user_uid=? AND channel_id=?').run('expired', toUid, channelId);
233
+ if (isBuyCmd) {
234
+ this._createPendingPayment(agentId, fromUid, toUid, pricingRow, timestamp);
235
+ } else {
236
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】⏰ 服务时间已到,对话已中断。如需继续使用,请回复"购买"续费。', timestamp);
237
+ }
238
+ return;
239
+ }
240
+ } else if (conv.session_status === 'expired') {
241
+ if (isBuyCmd) {
242
+ this._createPendingPayment(agentId, fromUid, toUid, pricingRow, timestamp);
243
+ } else {
244
+ this._sendSystemMessage(agentId, fromUid, '【系统消息】⏰ 服务时间已到,对话已中断。如需继续使用,请回复"购买"续费。', timestamp);
245
+ }
246
+ return;
247
+ }
248
+ }
249
+
250
+ // 入站消息审核
251
+ if (this._checkAuditRules) {
252
+ const auditResult = this._checkAuditRules(typeof content === 'string' ? content : String(content), 'inbound');
253
+ if (auditResult.action === 'hard_deny') {
254
+ const prompt = this._substitutePromptVariables(auditResult.matchedRule?.prompt || '', { keyword: auditResult.matchedKeyword, visitorId: fromUid, agentId });
255
+ if (prompt) this._sendSystemMessage(agentId, fromUid, '【系统消息】' + prompt, timestamp);
256
+ this._triggerAuditIntervention(agentId, fromUid, typeof content === 'string' ? content : String(content), auditResult, timestamp, messageId);
257
+ return;
258
+ }
259
+ if (auditResult.action === 'soft_deny') {
260
+ this._triggerAuditIntervention(agentId, fromUid, typeof content === 'string' ? content : String(content), auditResult, timestamp, messageId);
261
+ }
262
+ }
263
+
264
+ // 检查会话模式:MANUAL 时不转发
265
+ if (channelId) {
266
+ const convMode = this.db.prepare(`SELECT mode FROM conversations WHERE channel_id = ? AND agent_id = ?`).get(channelId, agentId);
267
+ if (convMode && convMode.mode === 'MANUAL') return;
268
+ }
269
+
270
+ if (skipForward) return;
271
+ this.forwardToAgent(agentId, fromUid, content, channelId, channelType, data.contentType, messageId, timestamp);
272
+ }
273
+
274
+ // ==========================================
275
+ // 好友申请介入
276
+ // ==========================================
277
+ _triggerFriendRequestIntervention(agentId, visitorId, content, timestamp) {
278
+ if (!this.ac || !this.databaseAPI) return;
279
+ const now = Date.now();
280
+ const oiId = `private_req_${now}_${Math.random().toString(36).substr(2, 6)}`;
281
+ const backendRow = this.db.prepare(`SELECT backend_type FROM agents WHERE agent_id = ?`).get(agentId);
282
+ const prefix = backendRow?.backend_type === 'hermes' ? 'hermes' : 'agent';
283
+ const visitorName = visitorId;
284
+ this.databaseAPI.saveOwnerIntervention({
285
+ id: oiId, visitorId, sessionKey: `${prefix}:${agentId}:${visitorId}`,
286
+ problem: `访客 "${visitorName}"(${visitorId}) 申请添加好友\n消息内容: "${content}"`,
287
+ agentSuggestion: '如同意请回复 "同意",主人回复后将自动添加该访客到白名单并通知访客。',
288
+ askTime: now, expireTime: null, status: 'pending',
289
+ ownerReply: null, replyTime: null, parentMessageId: null,
290
+ channelType: 'voko', resolvedAt: null, createdAt: now, updatedAt: now, agentId
291
+ });
292
+ this._enqueueIntervention({
293
+ id: oiId, visitorId, agentId, sessionKey: `${prefix}:${agentId}:${visitorId}`,
294
+ problem: `访客 "${visitorName}"(${visitorId}) 申请添加好友\n消息内容: "${content}"`,
295
+ agentSuggestion: '如同意请回复 "同意",主人回复后将自动添加该访客到白名单并通知访客。',
296
+ askTime: now, skipReply: 0,
297
+ });
298
+ this._onOwnerInterventionNew();
299
+ }
300
+
301
+ // ==========================================
302
+ // 审核介入
303
+ // ==========================================
304
+ _triggerAuditIntervention(agentId, visitorId, content, auditResult, timestamp, messageId) {
305
+ const now = Date.now();
306
+ const oiId = `audit_${now}_${Math.random().toString(36).substr(2, 6)}`;
307
+ const backendRow = this.db.prepare(`SELECT backend_type FROM agents WHERE agent_id = ?`).get(agentId);
308
+ const prefix = backendRow?.backend_type === 'hermes' ? 'hermes' : 'agent';
309
+ const actionLabel = auditResult.action === 'hard_deny' ? '系统已拒绝,自动回复提示语。' : '已转发给 Agent,请关注。';
310
+ if (this.databaseAPI) {
311
+ this.databaseAPI.saveOwnerIntervention({
312
+ id: oiId, visitorId, sessionKey: `${prefix}:${agentId}:${visitorId}`,
313
+ problem: `访客消息: "${content}"\n命中敏感词: "${auditResult.matchedKeyword}"\n${actionLabel}`,
314
+ agentSuggestion: '出站/入站关键词拦截提醒,无需回复',
315
+ askTime: now, expireTime: null, status: 'pending',
316
+ ownerReply: null, replyTime: null, parentMessageId: null,
317
+ channelType: 'voko', resolvedAt: null, createdAt: now, updatedAt: now, agentId
318
+ });
319
+ }
320
+ this.db.prepare('UPDATE owner_interventions SET skip_reply = 1 WHERE id = ?').run(oiId);
321
+ this._enqueueIntervention({
322
+ id: oiId, visitorId, agentId, sessionKey: `${prefix}:${agentId}:${visitorId}`,
323
+ problem: `访客消息: "${content}"\n命中敏感词: "${auditResult.matchedKeyword}"\n${actionLabel}`,
324
+ agentSuggestion: '出站/入站关键词拦截提醒,无需回复',
325
+ askTime: now, skipReply: 1,
326
+ });
327
+ this.insertBlockedMessage(agentId, visitorId, content, auditResult.matchedKeyword, auditResult.action, 'inbound', visitorId, timestamp, messageId);
328
+ this._onOwnerInterventionNew();
329
+ }
330
+
331
+ // ==========================================
332
+ // 好友申请自动审批
333
+ // ==========================================
334
+ autoApproveWhitelistIfFriendRequest(intervention, ownerReply) {
335
+ if (!this.ac) return;
336
+ return this.ac.autoApproveIfFriendRequest(this.db, this._sendSystemMessage, intervention, ownerReply);
337
+ }
338
+
339
+ // ==========================================
340
+ // 转发到 Agent 后端
341
+ // ==========================================
342
+ forwardToAgent(agentId, fromUid, content, channelId, channelType, contentType, messageId, timestamp) {
343
+ const agentBackendRow = this.db.prepare(`SELECT backend_type FROM agents WHERE agent_id = ?`).get(agentId);
344
+ const agentBackend = agentBackendRow?.backend_type || 'openclaw';
345
+
346
+ if (agentBackend !== 'openclaw' && agentBackend !== 'hermes') {
347
+ console.log(`[转发] agent=${agentId} 类型为 ${agentBackend},跳过转发`);
348
+ return;
349
+ }
350
+
351
+ const extraData = { channelId, channelType, contentType, messageId, timestamp };
352
+
353
+ if (agentBackend === 'hermes') {
354
+ if (this.hermesHandler?.enabled) {
355
+ const sessionKey = `hermes:${agentId}:${fromUid}`;
356
+ this.hermesHandler.sendToSession(sessionKey, content, extraData)
357
+ .then(() => console.log(`[转发] 已转发到 Hermes ${sessionKey}`))
358
+ .catch(err => console.error(`[转发] Hermes 失败:`, err.message));
359
+ } else {
360
+ this._notifyUI('show-tray-notification', {
361
+ title: '转发失败', body: 'Hermes Bridge 未就绪,消息无法转发'
362
+ });
363
+ }
364
+ } else if (this.openclawHandler) {
365
+ const sessionKey = `agent:${agentId}:${fromUid}`;
366
+ this.openclawHandler.sendToSession(sessionKey, content, extraData)
367
+ .then(() => console.log(`[转发] 已转发到 ${sessionKey}`))
368
+ .catch(err => console.error(`[转发] 失败:`, err.message));
369
+ } else {
370
+ this._notifyUI('show-tray-notification', {
371
+ title: '转发失败', body: 'OpenClaw WS 未连接,消息无法转发'
372
+ });
373
+ }
374
+ }
375
+
376
+ // ==========================================
377
+ // Agent 回复处理
378
+ // ==========================================
379
+ handleAgentReply(data) {
380
+ const { agentId, visitorId, content, sessionKey } = data;
381
+ if (!content || !content.trim()) return;
382
+
383
+ // 过滤系统消息(全大写 + 下划线)
384
+ if (/^[A-Z_]{3,}$/.test(content.trim())) {
385
+ console.log(`[Agent回复] 跳过系统消息: ${content.trim()}`);
386
+ return;
387
+ }
388
+
389
+ const trimmedContent = content.replace(/^[\n\r\s]+/, '').trim();
390
+ const agentRow = this.db.prepare('SELECT agent_id, imUid FROM agents WHERE agent_id = ?').get(agentId);
391
+ if (!agentRow) return;
392
+
393
+ const fromUid = agentRow.imUid;
394
+ const msgId = `agent-${agentId}-${visitorId}-${Date.now()}`;
395
+ const timestamp = Math.floor(Date.now() / 1000);
396
+
397
+ // 存储到数据库
398
+ try {
399
+ this.db.prepare(`INSERT INTO messages (id, from_uid, to_uid, content, channel_id, channel_type, agent_id, timestamp, is_me, status, message_seq, client_msg_no, no_persist, red_dot, sync_once, content_type)
400
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
401
+ .run(msgId, fromUid, visitorId, trimmedContent, visitorId, 1, agentId, timestamp, 1, 'sent', null, null, 0, 0, 0, 1);
402
+ console.log(`[Agent回复] 已存入DB id=${msgId} agent=${agentId} visitor=${visitorId} 字数=${trimmedContent.length}`);
403
+ } catch (e) {
404
+ if (!e.message.includes('UNIQUE constraint')) {
405
+ console.error('[Agent回复] 存储失败:', e.message);
406
+ }
407
+ }
408
+
409
+ // 更新会话
410
+ try {
411
+ const existConv = this.db.prepare('SELECT user_uid FROM conversations WHERE user_uid = ? AND channel_id = ?').get(fromUid, visitorId);
412
+ if (existConv) {
413
+ this.db.prepare('UPDATE conversations SET last_message = ?, last_timestamp = ? WHERE user_uid = ? AND channel_id = ?')
414
+ .run(trimmedContent, timestamp, fromUid, visitorId);
415
+ } else {
416
+ this.db.prepare('INSERT INTO conversations (user_uid, channel_id, channel_type, name, last_message, last_timestamp, unread_count, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
417
+ .run(fromUid, visitorId, 1, visitorId, trimmedContent, timestamp, 0, agentId);
418
+ }
419
+ } catch (e) {
420
+ console.error('[Agent回复] 会话更新失败:', e.message);
421
+ }
422
+
423
+ // 出站审核
424
+ if (this._checkAuditRules) {
425
+ const auditResult = this._checkAuditRules(trimmedContent, 'outbound');
426
+ if (auditResult.action === 'hard_deny') {
427
+ console.log(`[审核-出站] hard_deny agentId=${agentId} keyword="${auditResult.matchedKeyword}"`);
428
+ this.insertBlockedMessage(agentId, visitorId, trimmedContent, auditResult.matchedKeyword, 'hard_deny', 'outbound', fromUid, timestamp, msgId);
429
+ this._notifyUI('agent-wukongim:message', {
430
+ agentId, fromUid, toUid: visitorId, channelId: visitorId,
431
+ content: trimmedContent, messageId: msgId, timestamp, isMe: true
432
+ });
433
+ return;
434
+ }
435
+ if (auditResult.action === 'soft_deny') {
436
+ this.insertBlockedMessage(agentId, visitorId, trimmedContent, auditResult.matchedKeyword, 'soft_deny', 'outbound', fromUid, timestamp, msgId);
437
+ }
438
+ }
439
+
440
+ // 发送给访客
441
+ const workerEntry = this.agentWorkers?.get(agentId);
442
+ if (workerEntry) {
443
+ workerEntry.worker.send({ type: 'send', channelId: visitorId, content: trimmedContent, localMsgId: msgId });
444
+ } else {
445
+ console.error(`[Agent回复] 未找到 worker: ${agentId}`);
446
+ }
447
+
448
+ // 通知 UI
449
+ this._notifyUI('agent-wukongim:message', {
450
+ agentId, fromUid, toUid: visitorId, channelId: visitorId,
451
+ content: trimmedContent, contentType: 1, messageId: msgId, timestamp, isMe: true
452
+ });
453
+ }
454
+ }
455
+
456
+ module.exports = { MessageHandler };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * notifier.js — 新消息通知与提示音
3
+ *
4
+ * 主进程模块。收到访客消息时:
5
+ * - 前台运行 → 只播放自定义提示音
6
+ * - 托盘/最小化 → 播放提示音 + 弹系统通知
7
+ */
8
+
9
+ let Notification;
10
+ try { Notification = require('electron').Notification; } catch (_) { Notification = null; }
11
+ const { exec } = require('child_process');
12
+ const path = require('path');
13
+
14
+ let _mainWindow = null;
15
+ let _db = null;
16
+
17
+ // 播放自定义 MP3(主进程系统调用,无 CORS/自动播放限制)
18
+ function _playCustomSound() {
19
+ try {
20
+ const soundFile = path.join(__dirname, '..', '..', '..', '..', 'src', 'renderer', 'assets', 'notification-1-message.mp3');
21
+ if (process.platform === 'win32') {
22
+ const ps = [
23
+ `Add-Type -AssemblyName PresentationCore`,
24
+ `$p = New-Object System.Windows.Media.MediaPlayer`,
25
+ `$p.Open('${soundFile.replace(/'/g,"''")}')`,
26
+ `$p.Play()`,
27
+ `Start-Sleep 2`,
28
+ `$p.Close()`,
29
+ ].join(';');
30
+ exec(`powershell -NoProfile -Command "${ps}"`, { timeout: 5000, windowsHide: true }, (e) => {
31
+ if (e) console.log('[通知] 提示音播放失败:', e.message);
32
+ });
33
+ } else if (process.platform === 'darwin') {
34
+ exec(`afplay "${soundFile}" 2>/dev/null`, { timeout: 5000 }, () => {});
35
+ } else {
36
+ exec(`paplay "${soundFile}" 2>/dev/null || aplay "${soundFile}" 2>/dev/null`, { timeout: 5000 }, () => {});
37
+ }
38
+ } catch (_) {}
39
+ }
40
+
41
+ // 查询 Agent 名称
42
+ function _getAgentName(agentId) {
43
+ try {
44
+ const row = _db.prepare('SELECT agent_name FROM agents WHERE agent_id=?').get(agentId);
45
+ return row?.agent_name || agentId;
46
+ } catch (_) { return agentId; }
47
+ }
48
+
49
+ // 查询访客昵称
50
+ function _getVisitorName(visitorId) {
51
+ try {
52
+ const row = _db.prepare('SELECT nickname FROM user_cache WHERE uid=?').get(visitorId);
53
+ return row?.nickname || '';
54
+ } catch (_) { return ''; }
55
+ }
56
+
57
+ /**
58
+ * 初始化通知模块
59
+ * @param {object} mainWindow - BrowserWindow 实例
60
+ * @param {object} db - better-sqlite3 实例
61
+ */
62
+ function init(mainWindow, db) {
63
+ _mainWindow = mainWindow;
64
+ _db = db;
65
+ console.log('[通知] 模块初始化, mainWindow=' + (mainWindow ? '✅ 已创建' : '❌ null'));
66
+ }
67
+
68
+ /**
69
+ * 处理访客新消息通知
70
+ * @param {string} agentId
71
+ * @param {string} visitorId
72
+ * @param {string} content
73
+ * @param {number} timestamp
74
+ */
75
+ function notifyNewMessage(agentId, visitorId, content, timestamp) {
76
+ try {
77
+ _playCustomSound();
78
+
79
+ // 前台运行 → 只出声,不弹通知
80
+ const isFg = _mainWindow && _mainWindow.isVisible() && !_mainWindow.isMinimized();
81
+ console.log('[通知] 窗口状态: visible=' + (_mainWindow ? _mainWindow.isVisible() : '无窗口') + ' minimized=' + (_mainWindow ? _mainWindow.isMinimized() : '无窗口') + ' → ' + (isFg ? '前台(只出声)' : '托盘(弹通知)'));
82
+ if (isFg) {
83
+ return;
84
+ }
85
+
86
+ const agentName = _getAgentName(agentId);
87
+ const visitorName = _getVisitorName(visitorId);
88
+ const time = timestamp
89
+ ? new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
90
+ : '';
91
+ const sender = visitorName || visitorId;
92
+ const msgContent = (content || '').substring(0, 60);
93
+ const body = '发件人: ' + sender + '\n内容: ' + msgContent + (time ? ' ' + time : '');
94
+
95
+ new Notification({ title: '收件人: ' + agentName, body, silent: true }).show();
96
+ } catch (_) {}
97
+ }
98
+
99
+ module.exports = { init, notifyNewMessage };