@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.
- package/package.json +32 -0
- package/scripts/build-native.js +72 -0
- package/src/bankHeadOffices.js +20543 -0
- package/src/channels/email.js +35 -0
- package/src/channels/feishu.js +31 -0
- package/src/channels/qq-email.js +30 -0
- package/src/channels/registry.js +279 -0
- package/src/channels/telegram.js +28 -0
- package/src/channels/voko-email.js +7 -0
- package/src/channels/wechat.js +35 -0
- package/src/cli.js +120 -0
- package/src/context.js +164 -0
- package/src/core/access-control-api.js +150 -0
- package/src/core/access-control.js +56 -0
- package/src/core/agent-registration.js +319 -0
- package/src/core/api-signature.js +33 -0
- package/src/core/audit.js +133 -0
- package/src/core/database.js +1409 -0
- package/src/core/did-auth.js +54 -0
- package/src/core/hermes-paths.js +57 -0
- package/src/core/invitation.js +49 -0
- package/src/core/lite-bus.js +16 -0
- package/src/core/llm-client.js +1032 -0
- package/src/core/messenger.js +456 -0
- package/src/core/notifier.js +99 -0
- package/src/core/offline-sync.js +150 -0
- package/src/core/payment.js +285 -0
- package/src/core/publish-agent.js +166 -0
- package/src/core/register-capabilities.js +119 -0
- package/src/core/search-capabilities.js +136 -0
- package/src/core/send-message.js +85 -0
- package/src/core/set-agent-status.js +65 -0
- package/src/core/update-agent-profile.js +102 -0
- package/src/core/worker-manager.js +332 -0
- package/src/endpoints.json +21 -0
- package/src/index.js +712 -0
- package/src/mcp/CLAUDE_TEST.md +82 -0
- package/src/mcp/FULL_TEST.md +139 -0
- package/src/mcp/TEST.md +124 -0
- package/src/mcp/TEST_STEPS.md +75 -0
- package/src/mcp/server.js +612 -0
- package/src/mcp/tools.js +1367 -0
- package/src/mcp/transport/http.js +95 -0
- package/src/mcp/transport/stdio.js +20 -0
- package/src/preload.js +27 -0
- package/src/server/agent-email-api.js +120 -0
- package/src/server/agent-manager.js +580 -0
- package/src/server/email-handler.js +329 -0
- package/src/server/feishu-handler.js +249 -0
- package/src/server/hermes-api-client.js +166 -0
- package/src/server/hermes-discovery.js +80 -0
- package/src/server/hermes-handler.js +287 -0
- package/src/server/openclaw-handler-cli.js +131 -0
- package/src/server/openclaw-websocket-handler.js +1290 -0
- package/src/server/oss.js +186 -0
- package/src/server/owner-intervention-notifier.js +320 -0
- package/src/server/release-page.html +204 -0
- package/src/server/telegram-handler.js +208 -0
- package/src/server/voko-email-handler.js +68 -0
- package/src/server/wechat-handler.js +439 -0
- package/src/workers/agent-worker.js +378 -0
- package/src/workers/message-content.js +51 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSS 签名生成 - 用于前端直传文件到阿里云 OSS
|
|
3
|
+
* 采用 PostObject 直传模式:后端签名,前端直传
|
|
4
|
+
*/
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const ENDPOINTS = require('../endpoints.json');
|
|
7
|
+
|
|
8
|
+
// 默认配置(优先使用环境变量,其次 endpoints.json)
|
|
9
|
+
let OSS_REGION = process.env.OSS_REGION || ENDPOINTS.oss.region;
|
|
10
|
+
let OSS_BUCKET = process.env.OSS_BUCKET || ENDPOINTS.oss.bucket;
|
|
11
|
+
let OSS_ACCESS_KEY_ID = process.env.OSS_ACCESS_KEY_ID || '';
|
|
12
|
+
let OSS_ACCESS_KEY_SECRET = process.env.OSS_ACCESS_KEY_SECRET || '';
|
|
13
|
+
let OSS_ENDPOINT = process.env.OSS_ENDPOINT || ENDPOINTS.oss.endpoint;
|
|
14
|
+
let OSS_PUBLIC_URL = process.env.OSS_PUBLIC_URL || ENDPOINTS.oss.publicUrl;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从配置对象加载 OSS 配置(支持 DB / JSON 来源)
|
|
18
|
+
*/
|
|
19
|
+
function loadConfigFromObject(config) {
|
|
20
|
+
const ossConfig = config.oss_config;
|
|
21
|
+
if (!ossConfig) return false;
|
|
22
|
+
if (!OSS_ACCESS_KEY_ID && ossConfig.accessKeyId) OSS_ACCESS_KEY_ID = ossConfig.accessKeyId;
|
|
23
|
+
if (!OSS_ACCESS_KEY_SECRET && ossConfig.accessKeySecret) OSS_ACCESS_KEY_SECRET = ossConfig.accessKeySecret;
|
|
24
|
+
if (ossConfig.region) OSS_REGION = ossConfig.region;
|
|
25
|
+
if (ossConfig.bucket) OSS_BUCKET = ossConfig.bucket;
|
|
26
|
+
if (ossConfig.endpoint) OSS_ENDPOINT = ossConfig.endpoint;
|
|
27
|
+
if (ossConfig.publicUrl) OSS_PUBLIC_URL = ossConfig.publicUrl;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 供 main.js 在 DB 初始化后调用,从 DB 配置加载 OSS 凭证
|
|
33
|
+
*/
|
|
34
|
+
function initOSSFromConfig(config) {
|
|
35
|
+
if (OSS_ACCESS_KEY_ID && OSS_ACCESS_KEY_SECRET) return; // 环境变量优先
|
|
36
|
+
if (loadConfigFromObject(config)) {
|
|
37
|
+
console.log('[OSS] 从 SQLite 加载配置');
|
|
38
|
+
}
|
|
39
|
+
if (!OSS_ACCESS_KEY_ID || !OSS_ACCESS_KEY_SECRET) {
|
|
40
|
+
console.warn('[OSS] AccessKey 未配置,OSS 签名接口将不可用');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 生成 OSS PostObject 直传签名
|
|
46
|
+
* @param {string} objectName - OSS 中的 object key(如 chat/images/xxx.jpg)
|
|
47
|
+
* @param {string} contentType - 文件 MIME 类型(可选)
|
|
48
|
+
* @param {number} maxSize - 最大文件大小(字节),默认 100MB
|
|
49
|
+
* @returns {Object} 签名参数,前端可直接用于 FormData POST 到 OSS
|
|
50
|
+
*/
|
|
51
|
+
function generateOSSSignature(objectName, contentType = '', maxSize = 100 * 1024 * 1024) {
|
|
52
|
+
const expiration = new Date(Date.now() + 3600 * 1000).toISOString();
|
|
53
|
+
|
|
54
|
+
const conditions = [
|
|
55
|
+
['content-length-range', 0, maxSize],
|
|
56
|
+
{ bucket: OSS_BUCKET },
|
|
57
|
+
['eq', '$key', objectName]
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
if (contentType) {
|
|
61
|
+
conditions.push(['eq', '$Content-Type', contentType]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const disposition = objectName.startsWith('chat/images/') ? 'inline' : 'attachment';
|
|
65
|
+
conditions.push(['eq', '$Content-Disposition', disposition]);
|
|
66
|
+
|
|
67
|
+
const policyObj = { expiration, conditions };
|
|
68
|
+
const policy = Buffer.from(JSON.stringify(policyObj)).toString('base64');
|
|
69
|
+
const signature = crypto
|
|
70
|
+
.createHmac('sha1', OSS_ACCESS_KEY_SECRET)
|
|
71
|
+
.update(policy)
|
|
72
|
+
.digest('base64');
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
endpoint: OSS_ENDPOINT,
|
|
76
|
+
publicUrl: OSS_PUBLIC_URL,
|
|
77
|
+
bucket: OSS_BUCKET,
|
|
78
|
+
region: OSS_REGION,
|
|
79
|
+
key: objectName,
|
|
80
|
+
OSSAccessKeyId: OSS_ACCESS_KEY_ID,
|
|
81
|
+
policy,
|
|
82
|
+
Signature: signature,
|
|
83
|
+
contentType,
|
|
84
|
+
expiration
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 服务端上传 base64 图片到 OSS
|
|
90
|
+
* @param {string} base64DataUrl - data:image/xxx;base64,xxxxx
|
|
91
|
+
* @param {string} objectName - OSS object key,如 chat/pay/qr_xxx.png
|
|
92
|
+
* @returns {Promise<string>} 公开访问的图片 URL
|
|
93
|
+
*/
|
|
94
|
+
async function uploadBase64ToOSS(base64DataUrl, objectName, onProgress) {
|
|
95
|
+
if (!OSS_ACCESS_KEY_ID || !OSS_ACCESS_KEY_SECRET) {
|
|
96
|
+
throw new Error('OSS AccessKey 未配置');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const matches = base64DataUrl.match(/^data:(.+?);base64,(.+)$/);
|
|
100
|
+
if (!matches) throw new Error('无效的 base64 data URL');
|
|
101
|
+
const contentType = matches[1];
|
|
102
|
+
const base64Data = matches[2];
|
|
103
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
104
|
+
|
|
105
|
+
// OSS REST API PUT 签名(含 x-oss-object-acl 头)
|
|
106
|
+
const date = new Date().toUTCString();
|
|
107
|
+
const ossHeaders = 'x-oss-object-acl:public-read';
|
|
108
|
+
const resource = `/${OSS_BUCKET}/${objectName}`;
|
|
109
|
+
const stringToSign = `PUT\n\n${contentType}\n${date}\n${ossHeaders}\n${resource}`;
|
|
110
|
+
const signature = crypto
|
|
111
|
+
.createHmac('sha1', OSS_ACCESS_KEY_SECRET)
|
|
112
|
+
.update(stringToSign)
|
|
113
|
+
.digest('base64');
|
|
114
|
+
|
|
115
|
+
const url = `${OSS_PUBLIC_URL}/${objectName}`;
|
|
116
|
+
|
|
117
|
+
if (onProgress) onProgress(0);
|
|
118
|
+
const resp = await fetch(url, {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: {
|
|
121
|
+
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${signature}`,
|
|
122
|
+
'Content-Type': contentType,
|
|
123
|
+
'Date': date,
|
|
124
|
+
'Content-Length': String(buffer.length),
|
|
125
|
+
'x-oss-object-acl': 'public-read'
|
|
126
|
+
},
|
|
127
|
+
body: buffer
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (onProgress) onProgress(100);
|
|
131
|
+
if (!resp.ok) {
|
|
132
|
+
const text = await resp.text();
|
|
133
|
+
throw new Error(`OSS 上传失败 (${resp.status}): ${text}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return url;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 服务端上传任意内容到 OSS(通用版)
|
|
141
|
+
* @param {string} objectName - OSS object key
|
|
142
|
+
* @param {string|Buffer} content - 文件内容
|
|
143
|
+
* @param {string} contentType - MIME 类型
|
|
144
|
+
* @returns {Promise<string>} 公开访问的 URL
|
|
145
|
+
*/
|
|
146
|
+
async function uploadToOSS(objectName, content, contentType, onProgress) { return _uploadToOSS(objectName, content, contentType, onProgress); }
|
|
147
|
+
|
|
148
|
+
async function uploadToOSSWithProgress(objectName, content, contentType, onProgress) { return _uploadToOSS(objectName, content, contentType, onProgress); }
|
|
149
|
+
|
|
150
|
+
async function _uploadToOSS(objectName, content, contentType, onProgress) {
|
|
151
|
+
if (!OSS_ACCESS_KEY_ID || !OSS_ACCESS_KEY_SECRET) {
|
|
152
|
+
throw new Error('OSS AccessKey 未配置');
|
|
153
|
+
}
|
|
154
|
+
const buffer = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
|
|
155
|
+
const date = new Date().toUTCString();
|
|
156
|
+
const ossHeaders = 'x-oss-object-acl:public-read';
|
|
157
|
+
const resource = `/${OSS_BUCKET}/${objectName}`;
|
|
158
|
+
const stringToSign = `PUT\n\n${contentType}\n${date}\n${ossHeaders}\n${resource}`;
|
|
159
|
+
const signature = crypto
|
|
160
|
+
.createHmac('sha1', OSS_ACCESS_KEY_SECRET)
|
|
161
|
+
.update(stringToSign)
|
|
162
|
+
.digest('base64');
|
|
163
|
+
|
|
164
|
+
const url = `https://${OSS_BUCKET}.${OSS_REGION}.aliyuncs.com/${objectName}`;
|
|
165
|
+
|
|
166
|
+
const resp = await fetch(url, {
|
|
167
|
+
method: 'PUT',
|
|
168
|
+
headers: {
|
|
169
|
+
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${signature}`,
|
|
170
|
+
'Content-Type': contentType,
|
|
171
|
+
'Date': date,
|
|
172
|
+
'Content-Length': String(buffer.length),
|
|
173
|
+
'x-oss-object-acl': 'public-read'
|
|
174
|
+
},
|
|
175
|
+
body: buffer
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!resp.ok) {
|
|
179
|
+
const text = await resp.text();
|
|
180
|
+
throw new Error(`OSS 上传失败 (${resp.status}): ${text}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return `${OSS_PUBLIC_URL || OSS_ENDPOINT}/${objectName}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { generateOSSSignature, uploadBase64ToOSS, uploadToOSS, uploadToOSSWithProgress, initOSSFromConfig };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner Intervention Notifier — 事件驱动的通知模块
|
|
3
|
+
*
|
|
4
|
+
* 替代原有的轮询机制 (startOwnerInterventionPolling):
|
|
5
|
+
* - saveOwnerIntervention 完成后立即通知,无需等待轮询周期
|
|
6
|
+
* - 内置重试队列(指数退避)替代轮询重试
|
|
7
|
+
* - 启动时扫描 is_sent=0 的记录作为崩溃恢复兜底
|
|
8
|
+
*
|
|
9
|
+
* 使用方式:
|
|
10
|
+
* const notifier = new OwnerInterventionNotifier({ databaseAPI, registry, db, mainWindow });
|
|
11
|
+
* notifier.enqueue(record);
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAX_RETRIES = 5;
|
|
15
|
+
const BASE_BACKOFF_MS = 5000;
|
|
16
|
+
const MAX_RETRY_DELAY = 80000; // 80s cap
|
|
17
|
+
const STARTUP_SCAN_INTERVAL = 3000;
|
|
18
|
+
const bus = require('../core/lite-bus');
|
|
19
|
+
|
|
20
|
+
class OwnerInterventionNotifier {
|
|
21
|
+
constructor({ databaseAPI, registry, db, mainWindow, getEnabledChannel, agentEmailApi, getOpenclawHandler, getHermesHandler, buildOwnerReplyPrompt }) {
|
|
22
|
+
this.databaseAPI = databaseAPI;
|
|
23
|
+
this.registry = registry;
|
|
24
|
+
this.db = db;
|
|
25
|
+
this.mainWindow = mainWindow;
|
|
26
|
+
this.getEnabledChannel = getEnabledChannel;
|
|
27
|
+
this.agentEmailApi = agentEmailApi;
|
|
28
|
+
this.getOpenclawHandler = getOpenclawHandler || (() => null);
|
|
29
|
+
this.getHermesHandler = getHermesHandler || (() => null);
|
|
30
|
+
this.buildOwnerReplyPrompt = buildOwnerReplyPrompt || (() => '');
|
|
31
|
+
|
|
32
|
+
/** 重试队列: { [id]: { record, retryCount, timer } } */
|
|
33
|
+
this._retryQueue = {};
|
|
34
|
+
this._processing = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 启动时扫描 is_sent=0 的记录,补充处理(进程崩溃恢复)
|
|
39
|
+
*/
|
|
40
|
+
startScan() {
|
|
41
|
+
console.log('[OwnerInterventionNotifier] 启动恢复扫描...');
|
|
42
|
+
// 延迟执行,确保所有依赖都已就绪
|
|
43
|
+
setTimeout(() => this._recoverPending(), STARTUP_SCAN_INTERVAL);
|
|
44
|
+
// 启动邮件回复轮询
|
|
45
|
+
this.startEmailReplyPolling();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 停止所有重试定时器
|
|
50
|
+
*/
|
|
51
|
+
stop() {
|
|
52
|
+
for (const id of Object.keys(this._retryQueue)) {
|
|
53
|
+
clearTimeout(this._retryQueue[id].timer);
|
|
54
|
+
delete this._retryQueue[id];
|
|
55
|
+
}
|
|
56
|
+
this.stopEmailReplyPolling();
|
|
57
|
+
console.log('[OwnerInterventionNotifier] 已停止');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============ 公开方法 ============
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 入队一条干预记录并立即发送。
|
|
64
|
+
* 可在 saveOwnerIntervention 后直接调用。
|
|
65
|
+
*/
|
|
66
|
+
enqueue(record) {
|
|
67
|
+
if (!record || !record.id) {
|
|
68
|
+
console.warn('[OwnerInterventionNotifier] enqueue 跳过:无效记录');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
console.log('[OwnerInterventionNotifier] 入队通知, id:', record.id, 'visitor:', record.visitorId);
|
|
72
|
+
this._processRecord(record);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============ 私有方法 ============
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 处理单条记录:标记 is_sent=1 → 发送 → 更新 DB → 通知 UI
|
|
79
|
+
*/
|
|
80
|
+
async _processRecord(record) {
|
|
81
|
+
const channel = this.getEnabledChannel();
|
|
82
|
+
if (!channel) {
|
|
83
|
+
console.warn('[OwnerInterventionNotifier] 无可用渠道,标记失败, id:', record.id);
|
|
84
|
+
this.db.prepare(`UPDATE owner_interventions SET is_sent=1, status='failed', updated_at=? WHERE id=? AND is_sent=0`)
|
|
85
|
+
.run(Date.now(), record.id);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const channelType = channel.name;
|
|
90
|
+
const handler = this.registry.getHandler(channelType);
|
|
91
|
+
if (!handler || typeof handler.sendMessageToOwnerWithTracking !== 'function') {
|
|
92
|
+
console.warn('[OwnerInterventionNotifier] 渠道处理器不可用, id:', record.id, 'channelType:', channelType);
|
|
93
|
+
this.db.prepare(`UPDATE owner_interventions SET is_sent=1, status='failed', updated_at=? WHERE id=? AND is_sent=0`)
|
|
94
|
+
.run(Date.now(), record.id);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// 原子标记 is_sent=1,防止重复处理
|
|
100
|
+
const updateResult = this.db.prepare(
|
|
101
|
+
`UPDATE owner_interventions SET is_sent=1, updated_at=? WHERE id=? AND is_sent=0`
|
|
102
|
+
).run(Date.now(), record.id);
|
|
103
|
+
if (updateResult.changes === 0) {
|
|
104
|
+
console.log('[OwnerInterventionNotifier] 跳过,已被处理, id:', record.id);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 构造通知消息
|
|
109
|
+
const msgBody = `访客:${record.visitorId}
|
|
110
|
+
问题:${record.problem}
|
|
111
|
+
建议:${record.agentSuggestion || ""}
|
|
112
|
+
时间:${new Date(record.askTime).toLocaleString("zh-CN")}`;
|
|
113
|
+
|
|
114
|
+
console.log('[OwnerInterventionNotifier] 发送通知, id:', record.id, 'channelType:', channelType);
|
|
115
|
+
const result = await handler.sendMessageToOwnerWithTracking(
|
|
116
|
+
msgBody,
|
|
117
|
+
record.visitorId,
|
|
118
|
+
record.sessionKey,
|
|
119
|
+
record.agentId
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const sentMessageId = result?.sentMessageId || result?.messageId || `sent-${Date.now()}`;
|
|
123
|
+
console.log('[OwnerInterventionNotifier] 发送成功, id:', record.id, 'sentMessageId:', sentMessageId);
|
|
124
|
+
|
|
125
|
+
// 更新 DB
|
|
126
|
+
this.databaseAPI.updateOwnerInterventionSent(record.id, sentMessageId, channelType);
|
|
127
|
+
|
|
128
|
+
// 通知 UI
|
|
129
|
+
this._notifyUI(record, sentMessageId, channelType);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error('[OwnerInterventionNotifier] 发送失败, id:', record.id, err.message);
|
|
132
|
+
|
|
133
|
+
// 会话过期不重试
|
|
134
|
+
if (err.message?.startsWith('SESSION_EXPIRED')) {
|
|
135
|
+
this.db.prepare(
|
|
136
|
+
`UPDATE owner_interventions SET is_sent=1, status='failed', updated_at=? WHERE id=?`
|
|
137
|
+
).run(Date.now(), record.id);
|
|
138
|
+
console.log('[OwnerInterventionNotifier] 会话过期,标记失败, id:', record.id);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 回滚 is_sent=0,加入重试队列
|
|
143
|
+
try {
|
|
144
|
+
this.db.prepare(
|
|
145
|
+
`UPDATE owner_interventions SET is_sent=0, updated_at=? WHERE id=?`
|
|
146
|
+
).run(Date.now(), record.id);
|
|
147
|
+
} catch (e2) {
|
|
148
|
+
console.error('[OwnerInterventionNotifier] 回滚失败:', e2.message);
|
|
149
|
+
}
|
|
150
|
+
this._scheduleRetry(record, 0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 调度重试
|
|
156
|
+
*/
|
|
157
|
+
_scheduleRetry(record, currentRetryCount) {
|
|
158
|
+
const nextRetry = currentRetryCount + 1;
|
|
159
|
+
if (nextRetry > MAX_RETRIES) {
|
|
160
|
+
console.log('[OwnerInterventionNotifier] 超过最大重试次数,放弃, id:', record.id);
|
|
161
|
+
this.db.prepare(
|
|
162
|
+
`UPDATE owner_interventions SET is_sent=1, status='failed', updated_at=? WHERE id=?`
|
|
163
|
+
).run(Date.now(), record.id);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const delay = Math.min(BASE_BACKOFF_MS * Math.pow(2, nextRetry - 1), MAX_RETRY_DELAY);
|
|
168
|
+
console.log(`[OwnerInterventionNotifier] 计划重试, id: ${record.id}, 第${nextRetry}次, ${delay}ms后`);
|
|
169
|
+
|
|
170
|
+
if (this._retryQueue[record.id]) {
|
|
171
|
+
clearTimeout(this._retryQueue[record.id].timer);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this._retryQueue[record.id] = {
|
|
175
|
+
record: { ...record, retry_count: nextRetry },
|
|
176
|
+
timer: setTimeout(() => {
|
|
177
|
+
delete this._retryQueue[record.id];
|
|
178
|
+
// 重新从 DB 读取最新状态
|
|
179
|
+
const fresh = this.db.prepare('SELECT * FROM owner_interventions WHERE id = ?').get(record.id);
|
|
180
|
+
if (!fresh || fresh.is_sent === 1) {
|
|
181
|
+
console.log('[OwnerInterventionNotifier] 重试跳过,记录已被处理或不存在, id:', record.id);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this._processRecord({
|
|
185
|
+
id: fresh.id,
|
|
186
|
+
visitorId: fresh.visitor_id,
|
|
187
|
+
agentId: fresh.agent_id,
|
|
188
|
+
sessionKey: fresh.session_key,
|
|
189
|
+
problem: fresh.problem,
|
|
190
|
+
agentSuggestion: fresh.agent_suggestion,
|
|
191
|
+
askTime: fresh.ask_time,
|
|
192
|
+
skipReply: fresh.skip_reply,
|
|
193
|
+
retry_count: nextRetry,
|
|
194
|
+
});
|
|
195
|
+
}, delay),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 通知渲染进程
|
|
201
|
+
*/
|
|
202
|
+
_notifyUI(record, sentMessageId, channelType) {
|
|
203
|
+
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
|
204
|
+
bus.emit('feishu:owner-intervention-add', {
|
|
205
|
+
id: record.id,
|
|
206
|
+
visitorId: record.visitorId,
|
|
207
|
+
agentId: record.agentId || 'voko',
|
|
208
|
+
sessionKey: record.sessionKey,
|
|
209
|
+
problem: record.problem,
|
|
210
|
+
agentSuggestion: record.agentSuggestion,
|
|
211
|
+
sentMessageId,
|
|
212
|
+
channelType,
|
|
213
|
+
skipReply: record.skipReply || 0,
|
|
214
|
+
askTime: record.askTime,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 启动恢复:扫描所有 is_sent=0 的记录重新入队
|
|
221
|
+
*/
|
|
222
|
+
async _recoverPending() {
|
|
223
|
+
try {
|
|
224
|
+
const pending = this.databaseAPI.getPendingOwnerInterventions();
|
|
225
|
+
if (!pending || pending.length === 0) {
|
|
226
|
+
console.log('[OwnerInterventionNotifier] 启动扫描完毕,无待发送记录');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
console.log(`[OwnerInterventionNotifier] 启动扫描发现 ${pending.length} 条待发送记录`);
|
|
230
|
+
for (const item of pending) {
|
|
231
|
+
this._processRecord(item);
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error('[OwnerInterventionNotifier] 启动扫描失败:', err.message);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ============ 邮件回复轮询(每 5 秒) ============
|
|
239
|
+
|
|
240
|
+
startEmailReplyPolling() {
|
|
241
|
+
this.stopEmailReplyPolling();
|
|
242
|
+
this._emailReplyPollTimer = setInterval(() => this._pollEmailReplies(), 5000);
|
|
243
|
+
console.log('[OwnerInterventionNotifier] 邮件回复轮询已启动');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
stopEmailReplyPolling() {
|
|
247
|
+
if (this._emailReplyPollTimer) {
|
|
248
|
+
clearInterval(this._emailReplyPollTimer);
|
|
249
|
+
this._emailReplyPollTimer = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async _pollEmailReplies() {
|
|
254
|
+
if (!this.agentEmailApi) return;
|
|
255
|
+
try {
|
|
256
|
+
const rows = this.db.prepare(
|
|
257
|
+
`SELECT oi.id, oi.email_message_id, oi.agent_id, oi.visitor_id, oi.session_key, oi.problem
|
|
258
|
+
FROM owner_interventions oi
|
|
259
|
+
WHERE oi.email_message_id IS NOT NULL
|
|
260
|
+
AND (oi.status='pending' OR oi.status='awaiting')`
|
|
261
|
+
).all();
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
const reply = await this.agentEmailApi.queryReply({ message_id: row.email_message_id });
|
|
264
|
+
if (reply?.has_reply && reply.raw_text) {
|
|
265
|
+
const updateResult = this.databaseAPI.updateOwnerInterventionReply(
|
|
266
|
+
row.id, reply.raw_text,
|
|
267
|
+
Date.parse(reply.replied_at) || Date.now(), 'voko-email'
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// 转发回复给 agent(和渠道回复一致)
|
|
271
|
+
if (updateResult?.contentChanged && row.session_key && row.agent_id) {
|
|
272
|
+
const forwardMsg = this.buildOwnerReplyPrompt(
|
|
273
|
+
{ id: row.id, visitorId: row.visitor_id, problem: row.problem, agentId: row.agent_id },
|
|
274
|
+
reply.raw_text
|
|
275
|
+
);
|
|
276
|
+
const doNotify = () => {
|
|
277
|
+
this.databaseAPI.markAgentNotified(row.id);
|
|
278
|
+
this.databaseAPI.updateOwnerInterventionStatus(row.id, 'resolved', Date.now());
|
|
279
|
+
};
|
|
280
|
+
const backendRow = this.db.prepare('SELECT backend_type FROM agents WHERE agent_id = ?').get(row.agent_id);
|
|
281
|
+
const agentBackend = backendRow?.backend_type;
|
|
282
|
+
|
|
283
|
+
if (agentBackend === 'hermes') {
|
|
284
|
+
const hermesHandler = this.getHermesHandler();
|
|
285
|
+
const hermesSessionKey = 'hermes:' + row.agent_id + ':' + row.visitor_id;
|
|
286
|
+
if (hermesHandler?.connected) {
|
|
287
|
+
hermesHandler.steer(hermesSessionKey, forwardMsg).then(doNotify).catch(err => {
|
|
288
|
+
console.error('[OwnerInterventionNotifier] 邮件回复转发 Hermes 失败:', err.message);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const openclawHandler = this.getOpenclawHandler();
|
|
293
|
+
if (openclawHandler?.connected) {
|
|
294
|
+
openclawHandler.sendToSession(row.session_key, forwardMsg).then(doNotify).catch(err => {
|
|
295
|
+
console.error('[OwnerInterventionNotifier] 邮件回复转发 OpenClaw 失败:', err.message);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 通知 UI 定向更新(推一条,不重载)
|
|
302
|
+
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
|
303
|
+
bus.emit('owner-intervention:email-reply', {
|
|
304
|
+
id: row.id,
|
|
305
|
+
ownerReply: reply.raw_text,
|
|
306
|
+
replyTime: Date.parse(reply.replied_at) || Date.now(),
|
|
307
|
+
status: 'replied',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
console.log('[OwnerInterventionNotifier] 邮件回复已入库并转发, id:', row.id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch (e) {
|
|
315
|
+
console.warn('[OwnerInterventionNotifier] 邮件回复轮询错误:', e.message);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { OwnerInterventionNotifier };
|