evolclaw 2.0.0 → 2.0.2
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/data/evolclaw.sample.json +31 -26
- package/dist/channels/wechat.js +451 -0
- package/dist/cli.js +196 -146
- package/dist/config.js +32 -17
- package/dist/core/agent-runner.js +27 -41
- package/dist/core/command-handler.js +72 -54
- package/dist/core/message-processor.js +36 -10
- package/dist/core/message-queue.js +9 -3
- package/dist/core/session-manager.js +81 -238
- package/dist/index.js +189 -115
- package/dist/utils/init-feishu.js +261 -0
- package/dist/utils/init-wechat.js +170 -0
- package/dist/utils/init.js +120 -67
- package/dist/utils/stream-flusher.js +3 -2
- package/package.json +9 -7
|
@@ -1,39 +1,44 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
"agents": {
|
|
3
|
+
"anthropic": {
|
|
4
|
+
"baseUrl": "https://api.anthropic.com",
|
|
5
|
+
"apiKey": "your-api-key-here",
|
|
6
|
+
"model": "sonnet",
|
|
7
|
+
"useSettingSources": true,
|
|
8
|
+
"agentProgressSummaries": true
|
|
9
|
+
}
|
|
10
10
|
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
11
|
+
"channels": {
|
|
12
|
+
"feishu": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"appId": "your-feishu-app-id",
|
|
15
|
+
"appSecret": "your-feishu-app-secret",
|
|
16
|
+
"owner": ""
|
|
17
|
+
},
|
|
18
|
+
"wechat": {
|
|
19
|
+
"enabled": false,
|
|
20
|
+
"baseUrl": "https://ilinkai.weixin.qq.com",
|
|
21
|
+
"token": "",
|
|
22
|
+
"owner": ""
|
|
23
|
+
},
|
|
24
|
+
"aun": {
|
|
25
|
+
"enabled": true,
|
|
26
|
+
"domain": "your-aun-domain",
|
|
27
|
+
"agentName": "your-agent-name",
|
|
28
|
+
"owner": ""
|
|
29
|
+
}
|
|
14
30
|
},
|
|
15
31
|
"projects": {
|
|
16
32
|
"defaultPath": "/path/to/default/project",
|
|
17
33
|
"autoCreate": true,
|
|
18
34
|
"list": {
|
|
19
|
-
"project1": "/path/to/project1"
|
|
20
|
-
"project2": "/path/to/project2"
|
|
35
|
+
"project1": "/path/to/project1"
|
|
21
36
|
}
|
|
22
37
|
},
|
|
23
|
-
"flushDelay": 4000,
|
|
24
|
-
"timeout": {
|
|
25
|
-
"idle": 120000
|
|
26
|
-
},
|
|
27
38
|
"idleMonitor": {
|
|
28
39
|
"enabled": true,
|
|
29
|
-
"safeModeThreshold": 3
|
|
30
|
-
|
|
31
|
-
"sdk": {
|
|
32
|
-
"useSettingSources": true,
|
|
33
|
-
"agentProgressSummaries": true
|
|
40
|
+
"safeModeThreshold": 3,
|
|
41
|
+
"timeout": 120000
|
|
34
42
|
},
|
|
35
|
-
"
|
|
36
|
-
"feishu": "",
|
|
37
|
-
"aun": ""
|
|
38
|
-
}
|
|
43
|
+
"flushDelay": 4000
|
|
39
44
|
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { resolvePaths } from '../paths.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
const CHANNEL_VERSION = '1.0.0';
|
|
7
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
8
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
9
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
10
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
11
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
12
|
+
const RETRY_DELAY_MS = 2_000;
|
|
13
|
+
const TYPING_TICKET_TTL_MS = 5 * 60 * 1000; // 5 min cache
|
|
14
|
+
const SESSION_EXPIRED_ERRCODE = -14;
|
|
15
|
+
const SESSION_RETRY_DELAY_MS = 30_000; // 短暂停:30s 后重试一次
|
|
16
|
+
const SESSION_PAUSE_DURATION_MS = 10 * 60 * 1000; // 长暂停:10 分钟
|
|
17
|
+
const MSG_TYPE_USER = 1;
|
|
18
|
+
const MSG_TYPE_BOT = 2;
|
|
19
|
+
const MSG_ITEM_TEXT = 1;
|
|
20
|
+
const MSG_ITEM_VOICE = 3;
|
|
21
|
+
const MSG_STATE_FINISH = 2;
|
|
22
|
+
// ── Markdown → Plain Text ───────────────────────────────────────────────────
|
|
23
|
+
function markdownToPlainText(text) {
|
|
24
|
+
let result = text;
|
|
25
|
+
// Code blocks: strip fences, keep content
|
|
26
|
+
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
|
|
27
|
+
// Images: remove entirely
|
|
28
|
+
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
29
|
+
// Links: keep display text only
|
|
30
|
+
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
|
|
31
|
+
// Tables: remove separator rows
|
|
32
|
+
result = result.replace(/^\|[\s:|-]+\|$/gm, '');
|
|
33
|
+
result = result.replace(/^\|(.+)\|$/gm, (_, inner) => inner.split('|').map(cell => cell.trim()).join(' '));
|
|
34
|
+
// Bold/italic
|
|
35
|
+
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
36
|
+
result = result.replace(/\*(.+?)\*/g, '$1');
|
|
37
|
+
result = result.replace(/__(.+?)__/g, '$1');
|
|
38
|
+
result = result.replace(/_(.+?)_/g, '$1');
|
|
39
|
+
// Strikethrough
|
|
40
|
+
result = result.replace(/~~(.+?)~~/g, '$1');
|
|
41
|
+
// Inline code
|
|
42
|
+
result = result.replace(/`([^`]+)`/g, '$1');
|
|
43
|
+
// Headers
|
|
44
|
+
result = result.replace(/^#{1,6}\s+/gm, '');
|
|
45
|
+
// Blockquotes
|
|
46
|
+
result = result.replace(/^>\s?/gm, '');
|
|
47
|
+
// Horizontal rules
|
|
48
|
+
result = result.replace(/^[-*_]{3,}$/gm, '');
|
|
49
|
+
// List markers
|
|
50
|
+
result = result.replace(/^(\s*)[-*+]\s/gm, '$1');
|
|
51
|
+
result = result.replace(/^(\s*)\d+\.\s/gm, '$1');
|
|
52
|
+
return result.trim();
|
|
53
|
+
}
|
|
54
|
+
// ── Message Text Extraction ─────────────────────────────────────────────────
|
|
55
|
+
function extractTextFromMessage(msg) {
|
|
56
|
+
if (!msg.item_list?.length)
|
|
57
|
+
return '';
|
|
58
|
+
for (const item of msg.item_list) {
|
|
59
|
+
if (item.type === MSG_ITEM_TEXT && item.text_item?.text) {
|
|
60
|
+
const text = item.text_item.text;
|
|
61
|
+
const ref = item.ref_msg;
|
|
62
|
+
if (!ref)
|
|
63
|
+
return text;
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (ref.title)
|
|
66
|
+
parts.push(ref.title);
|
|
67
|
+
if (ref.message_item?.type === MSG_ITEM_TEXT && ref.message_item.text_item?.text) {
|
|
68
|
+
parts.push(ref.message_item.text_item.text);
|
|
69
|
+
}
|
|
70
|
+
if (!parts.length)
|
|
71
|
+
return text;
|
|
72
|
+
return `[引用: ${parts.join(' | ')}]\n${text}`;
|
|
73
|
+
}
|
|
74
|
+
if (item.type === MSG_ITEM_VOICE && item.voice_item?.text) {
|
|
75
|
+
return item.voice_item.text;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
// ── WechatChannel ───────────────────────────────────────────────────────────
|
|
81
|
+
export class WechatChannel {
|
|
82
|
+
config;
|
|
83
|
+
messageHandler;
|
|
84
|
+
abortController;
|
|
85
|
+
// 内部状态(不外泄到核心层)
|
|
86
|
+
contextTokenCache = new Map();
|
|
87
|
+
typingTicketCache = new Map();
|
|
88
|
+
getUpdatesBuf = '';
|
|
89
|
+
syncBufPath;
|
|
90
|
+
contextTokensPath;
|
|
91
|
+
// Session expired 状态
|
|
92
|
+
sessionPausedUntil = 0;
|
|
93
|
+
onSessionExpired;
|
|
94
|
+
constructor(config) {
|
|
95
|
+
this.config = config;
|
|
96
|
+
const dataDir = resolvePaths().dataDir;
|
|
97
|
+
this.syncBufPath = path.join(dataDir, 'wechat-sync-buf.txt');
|
|
98
|
+
this.contextTokensPath = path.join(dataDir, 'wechat-context-tokens.json');
|
|
99
|
+
}
|
|
100
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
101
|
+
onMessage(handler) {
|
|
102
|
+
this.messageHandler = handler;
|
|
103
|
+
}
|
|
104
|
+
/** 注册 session 过期通知回调(用于跨渠道通知用户) */
|
|
105
|
+
onSessionExpiredNotify(handler) {
|
|
106
|
+
this.onSessionExpired = handler;
|
|
107
|
+
}
|
|
108
|
+
/** 当前是否处于 session 暂停状态 */
|
|
109
|
+
isSessionPaused() {
|
|
110
|
+
return Date.now() < this.sessionPausedUntil;
|
|
111
|
+
}
|
|
112
|
+
async connect() {
|
|
113
|
+
if (!this.config.token) {
|
|
114
|
+
throw new Error('WeChat token not configured');
|
|
115
|
+
}
|
|
116
|
+
// 恢复游标
|
|
117
|
+
try {
|
|
118
|
+
if (fs.existsSync(this.syncBufPath)) {
|
|
119
|
+
this.getUpdatesBuf = fs.readFileSync(this.syncBufPath, 'utf-8');
|
|
120
|
+
logger.info(`[WeChat] Restored sync cursor (${this.getUpdatesBuf.length} bytes)`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
this.abortController = new AbortController();
|
|
127
|
+
// 启动长轮询(不 await,后台运行)
|
|
128
|
+
this.pollLoop(this.abortController.signal).catch(err => {
|
|
129
|
+
if (this.abortController?.signal.aborted)
|
|
130
|
+
return;
|
|
131
|
+
logger.error('[WeChat] Poll loop fatal error:', err);
|
|
132
|
+
});
|
|
133
|
+
logger.info('[WeChat] Channel connected');
|
|
134
|
+
}
|
|
135
|
+
async disconnect() {
|
|
136
|
+
if (this.abortController) {
|
|
137
|
+
this.abortController.abort();
|
|
138
|
+
this.abortController = undefined;
|
|
139
|
+
}
|
|
140
|
+
logger.info('[WeChat] Channel disconnected');
|
|
141
|
+
}
|
|
142
|
+
async sendMessage(to, text) {
|
|
143
|
+
if (!text || text.trim() === '') {
|
|
144
|
+
logger.warn('[WeChat] Attempted to send empty message, skipping');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Session 暂停期间拒绝发送
|
|
148
|
+
if (this.isSessionPaused()) {
|
|
149
|
+
const remainingMin = Math.ceil((this.sessionPausedUntil - Date.now()) / 60_000);
|
|
150
|
+
logger.warn(`[WeChat] Session paused, ${remainingMin}min remaining, dropping outbound to ${to}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const contextToken = this.contextTokenCache.get(to);
|
|
154
|
+
if (!contextToken) {
|
|
155
|
+
logger.error(`[WeChat] No context_token for ${to}, cannot send message`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Markdown → 纯文本
|
|
159
|
+
const plainText = markdownToPlainText(text);
|
|
160
|
+
const clientId = `evolclaw-wechat:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
161
|
+
const body = {
|
|
162
|
+
msg: {
|
|
163
|
+
from_user_id: '',
|
|
164
|
+
to_user_id: to,
|
|
165
|
+
client_id: clientId,
|
|
166
|
+
message_type: MSG_TYPE_BOT,
|
|
167
|
+
message_state: MSG_STATE_FINISH,
|
|
168
|
+
item_list: [{ type: MSG_ITEM_TEXT, text_item: { text: plainText } }],
|
|
169
|
+
context_token: contextToken,
|
|
170
|
+
},
|
|
171
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
172
|
+
};
|
|
173
|
+
try {
|
|
174
|
+
await this.apiFetch('ilink/bot/sendmessage', JSON.stringify(body), DEFAULT_API_TIMEOUT_MS);
|
|
175
|
+
logger.debug(`[WeChat] Sent message to ${to}, clientId=${clientId}`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
logger.error(`[WeChat] Failed to send message to ${to}:`, err);
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ── Long-Poll Loop ────────────────────────────────────────────────────
|
|
183
|
+
async pollLoop(signal) {
|
|
184
|
+
let consecutiveFailures = 0;
|
|
185
|
+
let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
186
|
+
logger.info('[WeChat] Starting message polling...');
|
|
187
|
+
while (!signal.aborted) {
|
|
188
|
+
try {
|
|
189
|
+
const body = JSON.stringify({
|
|
190
|
+
get_updates_buf: this.getUpdatesBuf,
|
|
191
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
192
|
+
});
|
|
193
|
+
let rawText;
|
|
194
|
+
try {
|
|
195
|
+
rawText = await this.apiFetch('ilink/bot/getupdates', body, nextTimeoutMs, signal);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
return;
|
|
200
|
+
// 长轮询超时是正常的
|
|
201
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
const resp = JSON.parse(rawText);
|
|
207
|
+
// 更新服务端建议的轮询超时
|
|
208
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
|
|
209
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
210
|
+
}
|
|
211
|
+
// API 错误处理
|
|
212
|
+
const isError = (resp.ret !== undefined && resp.ret !== 0) ||
|
|
213
|
+
(resp.errcode !== undefined && resp.errcode !== 0);
|
|
214
|
+
if (isError) {
|
|
215
|
+
// Session expired 专用处理
|
|
216
|
+
const isSessionExpired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
|
|
217
|
+
if (isSessionExpired) {
|
|
218
|
+
consecutiveFailures = 0;
|
|
219
|
+
logger.error(`[WeChat] Session expired (errcode=${resp.errcode}), retrying in ${SESSION_RETRY_DELAY_MS / 1000}s...`);
|
|
220
|
+
// 短暂停后重试一次
|
|
221
|
+
await this.sleep(SESSION_RETRY_DELAY_MS, signal);
|
|
222
|
+
if (signal.aborted)
|
|
223
|
+
return;
|
|
224
|
+
// 重试 getupdates
|
|
225
|
+
try {
|
|
226
|
+
const retryBody = JSON.stringify({
|
|
227
|
+
get_updates_buf: this.getUpdatesBuf,
|
|
228
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
229
|
+
});
|
|
230
|
+
const retryRaw = await this.apiFetch('ilink/bot/getupdates', retryBody, nextTimeoutMs, signal);
|
|
231
|
+
const retryResp = JSON.parse(retryRaw);
|
|
232
|
+
const retryExpired = retryResp.errcode === SESSION_EXPIRED_ERRCODE || retryResp.ret === SESSION_EXPIRED_ERRCODE;
|
|
233
|
+
if (!retryExpired) {
|
|
234
|
+
// 恢复成功,静默继续
|
|
235
|
+
logger.info('[WeChat] Session recovered after retry');
|
|
236
|
+
// 把 retryResp 当正常响应处理(更新游标和消息)
|
|
237
|
+
if (retryResp.get_updates_buf) {
|
|
238
|
+
this.getUpdatesBuf = retryResp.get_updates_buf;
|
|
239
|
+
try {
|
|
240
|
+
fs.writeFileSync(this.syncBufPath, this.getUpdatesBuf, 'utf-8');
|
|
241
|
+
}
|
|
242
|
+
catch { }
|
|
243
|
+
}
|
|
244
|
+
for (const msg of retryResp.msgs ?? []) {
|
|
245
|
+
await this.handleInboundMessage(msg);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (retryErr) {
|
|
251
|
+
if (signal.aborted)
|
|
252
|
+
return;
|
|
253
|
+
logger.error('[WeChat] Retry after session expired also failed:', retryErr);
|
|
254
|
+
}
|
|
255
|
+
// 重试仍失败,进入长暂停
|
|
256
|
+
const pauseMin = SESSION_PAUSE_DURATION_MS / 60_000;
|
|
257
|
+
this.sessionPausedUntil = Date.now() + SESSION_PAUSE_DURATION_MS;
|
|
258
|
+
logger.error(`[WeChat] Session still expired, pausing for ${pauseMin}min`);
|
|
259
|
+
// 通知用户(通过其他渠道)
|
|
260
|
+
if (this.onSessionExpired) {
|
|
261
|
+
this.onSessionExpired(`⚠️ 微信 token 已过期,通道暂停 ${pauseMin} 分钟后自动重试。\n如需立即恢复,请运行: evolclaw init wechat`);
|
|
262
|
+
}
|
|
263
|
+
await this.sleep(SESSION_PAUSE_DURATION_MS, signal);
|
|
264
|
+
if (signal.aborted)
|
|
265
|
+
return;
|
|
266
|
+
// 长暂停结束,清除暂停状态,循环自动重试
|
|
267
|
+
this.sessionPausedUntil = 0;
|
|
268
|
+
logger.info('[WeChat] Session pause ended, resuming polling');
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
consecutiveFailures++;
|
|
272
|
+
logger.error(`[WeChat] getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ''}`);
|
|
273
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
274
|
+
logger.error(`[WeChat] ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off ${BACKOFF_DELAY_MS / 1000}s`);
|
|
275
|
+
consecutiveFailures = 0;
|
|
276
|
+
await this.sleep(BACKOFF_DELAY_MS, signal);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
await this.sleep(RETRY_DELAY_MS, signal);
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
consecutiveFailures = 0;
|
|
284
|
+
// 保存游标
|
|
285
|
+
if (resp.get_updates_buf) {
|
|
286
|
+
this.getUpdatesBuf = resp.get_updates_buf;
|
|
287
|
+
try {
|
|
288
|
+
fs.writeFileSync(this.syncBufPath, this.getUpdatesBuf, 'utf-8');
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// best-effort
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// 处理消息
|
|
295
|
+
for (const msg of resp.msgs ?? []) {
|
|
296
|
+
await this.handleInboundMessage(msg);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
if (signal.aborted)
|
|
301
|
+
return;
|
|
302
|
+
consecutiveFailures++;
|
|
303
|
+
logger.error(`[WeChat] Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}):`, err);
|
|
304
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
305
|
+
consecutiveFailures = 0;
|
|
306
|
+
await this.sleep(BACKOFF_DELAY_MS, signal);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
await this.sleep(RETRY_DELAY_MS, signal);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ── Inbound Message Handler ──────────────────────────────────────────
|
|
315
|
+
async handleInboundMessage(msg) {
|
|
316
|
+
if (msg.message_type !== MSG_TYPE_USER)
|
|
317
|
+
return;
|
|
318
|
+
const text = extractTextFromMessage(msg);
|
|
319
|
+
if (!text)
|
|
320
|
+
return;
|
|
321
|
+
const fromUserId = msg.from_user_id ?? '';
|
|
322
|
+
// 缓存 context_token
|
|
323
|
+
if (msg.context_token) {
|
|
324
|
+
this.contextTokenCache.set(fromUserId, msg.context_token);
|
|
325
|
+
this.persistContextTokens();
|
|
326
|
+
}
|
|
327
|
+
logger.info(`[WeChat] Received: from=${fromUserId} text=${text.slice(0, 50)}...`);
|
|
328
|
+
// 发送 typing 指示器(异步,不阻塞)
|
|
329
|
+
this.acknowledgeMessage(fromUserId, msg.context_token).catch(() => { });
|
|
330
|
+
// 回调主流程
|
|
331
|
+
if (this.messageHandler) {
|
|
332
|
+
try {
|
|
333
|
+
await this.messageHandler(fromUserId, text, fromUserId);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
logger.error('[WeChat] Message handler error:', err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ── Acknowledge (sendTyping) ──────────────────────────────────────────
|
|
341
|
+
async acknowledgeMessage(fromUserId, contextToken) {
|
|
342
|
+
try {
|
|
343
|
+
const ticket = await this.getTypingTicket(fromUserId, contextToken);
|
|
344
|
+
if (!ticket)
|
|
345
|
+
return;
|
|
346
|
+
const body = JSON.stringify({
|
|
347
|
+
ilink_user_id: fromUserId,
|
|
348
|
+
typing_ticket: ticket,
|
|
349
|
+
status: 1, // typing
|
|
350
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
351
|
+
});
|
|
352
|
+
await this.apiFetch('ilink/bot/sendtyping', body, DEFAULT_CONFIG_TIMEOUT_MS);
|
|
353
|
+
logger.debug(`[WeChat] Sent typing indicator to ${fromUserId}`);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// 静默失败,不阻塞主流程(和 Feishu addAckReaction 一致)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async getTypingTicket(userId, contextToken) {
|
|
360
|
+
const cached = this.typingTicketCache.get(userId);
|
|
361
|
+
if (cached && Date.now() - cached.fetchedAt < TYPING_TICKET_TTL_MS) {
|
|
362
|
+
return cached.ticket;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const body = JSON.stringify({
|
|
366
|
+
ilink_user_id: userId,
|
|
367
|
+
context_token: contextToken,
|
|
368
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
369
|
+
});
|
|
370
|
+
const rawText = await this.apiFetch('ilink/bot/getconfig', body, DEFAULT_CONFIG_TIMEOUT_MS);
|
|
371
|
+
const resp = JSON.parse(rawText);
|
|
372
|
+
if (resp.ret === 0 && resp.typing_ticket) {
|
|
373
|
+
this.typingTicketCache.set(userId, { ticket: resp.typing_ticket, fetchedAt: Date.now() });
|
|
374
|
+
return resp.typing_ticket;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// ignore
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
// ── ilink API Helpers ─────────────────────────────────────────────────
|
|
383
|
+
async apiFetch(endpoint, body, timeoutMs, externalSignal) {
|
|
384
|
+
const base = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`;
|
|
385
|
+
const url = new URL(endpoint, base).toString();
|
|
386
|
+
const headers = this.buildHeaders(body);
|
|
387
|
+
const controller = new AbortController();
|
|
388
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
389
|
+
// 外部 signal(来自 disconnect)也要能中断
|
|
390
|
+
const onExternalAbort = () => controller.abort();
|
|
391
|
+
externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
|
|
392
|
+
try {
|
|
393
|
+
const res = await fetch(url, {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers,
|
|
396
|
+
body,
|
|
397
|
+
signal: controller.signal,
|
|
398
|
+
});
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
const text = await res.text();
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
403
|
+
return text;
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
buildHeaders(body) {
|
|
414
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
415
|
+
const wechatUin = Buffer.from(String(uint32), 'utf-8').toString('base64');
|
|
416
|
+
const headers = {
|
|
417
|
+
'Content-Type': 'application/json',
|
|
418
|
+
'AuthorizationType': 'ilink_bot_token',
|
|
419
|
+
'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
|
|
420
|
+
'X-WECHAT-UIN': wechatUin,
|
|
421
|
+
};
|
|
422
|
+
if (this.config.token?.trim()) {
|
|
423
|
+
headers['Authorization'] = `Bearer ${this.config.token.trim()}`;
|
|
424
|
+
}
|
|
425
|
+
return headers;
|
|
426
|
+
}
|
|
427
|
+
// ── Persistence ────────────────────────────────────────────────────
|
|
428
|
+
/** 持久化 context_token 到文件,供 restart-monitor 等外部进程读取 */
|
|
429
|
+
persistContextTokens() {
|
|
430
|
+
try {
|
|
431
|
+
const obj = {};
|
|
432
|
+
for (const [k, v] of this.contextTokenCache) {
|
|
433
|
+
obj[k] = v;
|
|
434
|
+
}
|
|
435
|
+
fs.writeFileSync(this.contextTokensPath, JSON.stringify(obj), 'utf-8');
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// best-effort
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// ── Utilities ─────────────────────────────────────────────────────────
|
|
442
|
+
sleep(ms, signal) {
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
const t = setTimeout(resolve, ms);
|
|
445
|
+
signal.addEventListener('abort', () => {
|
|
446
|
+
clearTimeout(t);
|
|
447
|
+
reject(new Error('aborted'));
|
|
448
|
+
}, { once: true });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|