dsh-plugin-weapp-gateway 0.8.12 → 0.8.13

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.
@@ -0,0 +1,974 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import QRCode from 'qrcode';
3
+ import { ClawbotError } from './contracts.mjs';
4
+ import { ILinkClient } from './ilink.mjs';
5
+ import { MEDIA_MAX_BYTES, MEDIA_MAX_ITEMS } from './ilink-media.mjs';
6
+ import { validateClawbotData } from './storage.mjs';
7
+ import { REPLY_TEXT_MAX_BYTES, REPLY_MAX_NOTICES, REPLY_NOTICE_MAX_BYTES, splitText } from './reply-text.mjs';
8
+ export { splitText } from './reply-text.mjs';
9
+ const RETENTION = 7 * 86400_000; // Local deduplication window, not a Tencent guarantee.
10
+ const MAX_RECEIPTS = 1000;
11
+ const MAX_INPUT = 8000;
12
+ const LOGIN_LIFETIME = 5 * 60_000; // Local attempt limit, not a Tencent expiry guarantee.
13
+ const LOGIN_RETRY_MESSAGE = '暂时无法读取微信绑定状态,正在重试。';
14
+ const RESTART_MESSAGE = 'ClawBot 本地记录出现异常,接入已停止。请核对记录后重启组件。';
15
+ const activeLoginStatus = (status) => ['wait', 'scaned', 'scaned_but_redirect', 'need_verifycode'].includes(status);
16
+ const terminal = (r) => r.state === 'done' || r.state === 'uncertain';
17
+ const isCommand = (r) => !r.media?.length && /^\/(?:帮助|help|状态|会话|回答|切换)(?:\s|$)/.test(r.input.trim());
18
+ const attentionId = (id, kind) => createHash('sha256').update(`attention\0${id}\0${kind}`).digest('hex');
19
+ const nativeOnlyMessage = '请在小程序或电脑处理当前问答或审批。本条消息未发送,也不会稍后补发。';
20
+ const sameTarget = (a, b) => a.userId === b.userId && !!a.target && !!b.target
21
+ && a.target.id === b.target.id && a.target.generation === b.target.generation;
22
+ function holdInput(r, output = nativeOnlyMessage) {
23
+ r.state = 'ready';
24
+ r.input = '';
25
+ r.output = output;
26
+ }
27
+ function holdPending(data, parent, output) {
28
+ for (const r of data.receipts)
29
+ if (r.state === 'pending' && !isCommand(r)
30
+ && r.createdAt >= parent.createdAt && sameTarget(r, parent))
31
+ holdInput(r, output);
32
+ }
33
+ function sealUnsentAttention(data, id) {
34
+ const ids = new Set([attentionId(id, 'approval'), attentionId(id, 'question')]);
35
+ for (const r of data.receipts)
36
+ if ((ids.has(r.id) || r.parentId === id) && r.state === 'ready') {
37
+ r.state = 'done';
38
+ r.input = '';
39
+ r.output = undefined;
40
+ r.parts = undefined;
41
+ r.contextToken = '';
42
+ }
43
+ }
44
+ const fail = (code, message) => { throw new ClawbotError(code, message); };
45
+ // Fixed local messages only: never expose provider bodies, credentials or URLs.
46
+ function pollErrorMessage(code) {
47
+ switch (code) {
48
+ case 'clawbot-auth-expired': return '微信连接已失效,请重新绑定。';
49
+ case 'clawbot-capacity': return '本机收件记录已满,接入已暂停;原记录未删除。';
50
+ case 'clawbot-invalid-response': return '微信返回的数据暂不兼容,正在重试;持续出现时请更新插件。';
51
+ case 'clawbot-response-too-large': return '微信消息超过本地处理上限,正在重试;本批消息尚未接入。';
52
+ case 'clawbot-untrusted-url': return '微信接口地址校验未通过,正在重试;请核对插件版本。';
53
+ case 'clawbot-api-error':
54
+ case 'clawbot-http-error': return '微信服务暂未接受请求,正在重试;不会重复提交已有任务。';
55
+ case 'clawbot-network-error':
56
+ case 'clawbot-timeout': return 'ClawBot 连接暂不可用,正在等待重连;不会重复提交已有任务。';
57
+ default: return 'ClawBot 接入处理异常,正在重试;请核对插件状态。';
58
+ }
59
+ }
60
+ /** Independently owned channel. No phone connection, relay account, model tool,
61
+ * native preset mutation, or host process lifecycle is used here. */
62
+ export class ClawbotRuntime {
63
+ storage;
64
+ host;
65
+ client;
66
+ now;
67
+ access;
68
+ data;
69
+ lifetime = new AbortController();
70
+ polling;
71
+ pollTask;
72
+ worker;
73
+ commandWorker;
74
+ deliveryWorker;
75
+ broken = false;
76
+ login;
77
+ loginPoll;
78
+ loginWatch;
79
+ message = '';
80
+ state = 'disabled';
81
+ closed = false;
82
+ commandQueue = Promise.resolve();
83
+ nativeAttention = new Set();
84
+ constructor(storage, host, client = new ILinkClient(), now = Date.now, access) {
85
+ this.storage = storage;
86
+ this.host = host;
87
+ this.client = client;
88
+ this.now = now;
89
+ this.access = access;
90
+ this.data = storage.read();
91
+ // A hot component reload may retain the phone/gateway connection epoch but
92
+ // never restores an in-memory QR attempt. Fence all prior instance replies.
93
+ if (this.data.revision > 0)
94
+ this.commit(d => { d.revision++; });
95
+ // RC32's private question capability cannot survive an upgrade. Retire its
96
+ // unsent prompts and possible queued answers, never reinterpret or replay
97
+ // them as new Agent tasks. This only changes the plugin's private outbox.
98
+ if (this.data.receipts.some(r => r.parentId !== undefined))
99
+ this.commit(d => {
100
+ const parents = new Set(d.receipts.map(r => r.parentId).filter(Boolean));
101
+ // The original run may have completed after RC32 was paused, while its
102
+ // ambiguous follow-up is still queued. Do not infer new task
103
+ // intent from an old answer-shaped message just because the run ended.
104
+ for (const r of d.receipts)
105
+ if (parents.has(r.id))
106
+ holdPending(d, r, '本条升级前待发消息未发送到会话,也不会自动补发。请在小程序或电脑核对原会话后,按需重新发送。');
107
+ for (const r of d.receipts)
108
+ if (r.parentId !== undefined) {
109
+ if (r.state === 'ready' || r.state === 'sending') {
110
+ r.state = r.state === 'sending' ? 'uncertain' : 'done';
111
+ r.input = '';
112
+ r.output = undefined;
113
+ r.parts = undefined;
114
+ r.contextToken = '';
115
+ }
116
+ delete r.parentId; // One-time retirement, not a permanent block on this session.
117
+ }
118
+ });
119
+ // After an interrupted process, neither a host dispatch nor a provider
120
+ // delivery can safely be repeated just because its acknowledgement is lost.
121
+ if (this.data.receipts.some(r => r.state === 'running' || r.state === 'sending')) {
122
+ this.commit(d => {
123
+ for (const r of d.receipts)
124
+ if (r.state === 'running' || r.state === 'sending') {
125
+ sealUnsentAttention(d, r.id);
126
+ r.state = 'uncertain';
127
+ r.input = '';
128
+ r.output = undefined;
129
+ r.parts = undefined;
130
+ r.contextToken = '';
131
+ }
132
+ });
133
+ this.message = '有上次中断的处理或发送,未自动重试,请在原会话核对。';
134
+ }
135
+ // Pre-entitlement versions never captured an account owner. Do not adopt
136
+ // their token in the background: the owner must explicitly enable once.
137
+ if (access?.identity && this.data.enabled && !this.data.accessBinding) {
138
+ this.commit(d => { d.enabled = false; d.revision++; });
139
+ this.message = '升级后请核对绑定的微信,并重新启用私聊接入。原绑定和会话已保留。';
140
+ }
141
+ this.resume();
142
+ }
143
+ commit(change) {
144
+ const next = structuredClone(this.data);
145
+ change(next);
146
+ // Download capabilities are only needed before native admission. Do not
147
+ // retain provider URLs/keys in terminal deliveries or blocked inputs.
148
+ for (const r of next.receipts)
149
+ if (!['pending', 'running'].includes(r.state))
150
+ delete r.media;
151
+ validateClawbotData(next);
152
+ this.storage.write(next);
153
+ this.data = next;
154
+ }
155
+ checkRevision(request) {
156
+ if (request.expectedRevision !== this.data.revision)
157
+ fail('clawbot-conflict', '状态已经变化,请刷新后再操作。');
158
+ }
159
+ checkOperational(request) {
160
+ // Recovering disk space does not recover this stopped runtime. In particular,
161
+ // never return a successful enable/bind receipt while resume() is fenced off.
162
+ const safe = request?.action === 'login.cancel' || request?.action === 'enabled' && request.enabled === false;
163
+ if (this.broken && !safe)
164
+ fail('clawbot-restart-required', RESTART_MESSAGE);
165
+ }
166
+ accessFailure(error) {
167
+ const message = error instanceof Error ? error.message : '暂时无法核对使用资格,请联网后刷新。';
168
+ this.polling?.abort();
169
+ try {
170
+ if (this.data.enabled)
171
+ this.commit(d => { d.enabled = false; d.revision++; });
172
+ }
173
+ catch {
174
+ this.broken = true;
175
+ this.state = 'error';
176
+ this.message = RESTART_MESSAGE;
177
+ }
178
+ if (this.login && activeLoginStatus(this.login.status)) {
179
+ this.commit(d => { d.revision++; });
180
+ this.login.status = 'error';
181
+ this.endLogin(this.login);
182
+ }
183
+ if (!this.broken)
184
+ this.message = message;
185
+ return fail('feature-access-denied', message);
186
+ }
187
+ async authorize() { try {
188
+ await this.access?.authorize();
189
+ }
190
+ catch (error) {
191
+ this.accessFailure(error);
192
+ } ; this.currentAccess(); }
193
+ sameOwner(a, b) { return a.nodeId === b.nodeId && a.accountInstance === b.accountInstance; }
194
+ currentAccess() {
195
+ try {
196
+ this.access?.current();
197
+ const owner = this.access?.identity?.();
198
+ if (owner && ((this.data.accessBinding && !this.sameOwner(this.data.accessBinding, owner)) || (this.login?.owner && !this.sameOwner(this.login.owner, owner))))
199
+ throw new Error('此节点的账号绑定已变化。请移除原本地微信绑定后重新绑定,不会沿用旧账号的连接。');
200
+ }
201
+ catch (error) {
202
+ this.accessFailure(error);
203
+ }
204
+ }
205
+ requiresAccess(request) {
206
+ return !['status', 'sessions', 'login.cancel', 'disconnect'].includes(request.action) && !(request.action === 'enabled' && request.enabled === false);
207
+ }
208
+ snapshot() {
209
+ this.expireLogin();
210
+ return { supported: true, revision: this.data.revision, enabled: this.data.enabled,
211
+ state: !this.data.enabled ? 'disabled' : !this.data.credentials ? 'unpaired' : this.state,
212
+ paired: Boolean(this.data.credentials), bindingState: !this.data.credentials ? 'unpaired' : this.data.authExpired ? 'expired' : 'paired',
213
+ target: this.data.target && { ...this.data.target }, message: this.message,
214
+ pending: this.data.receipts.filter(r => !terminal(r)).length,
215
+ uncertain: this.data.receipts.filter(r => r.state === 'uncertain').length,
216
+ ...(this.login ? { pairing: { id: this.login.id, url: this.login.url, qrDataUrl: this.login.qrDataUrl,
217
+ status: this.login.status, expiresAt: this.login.started + LOGIN_LIFETIME } } : {}) };
218
+ }
219
+ endLogin(login) {
220
+ if (this.loginWatch?.login === login) {
221
+ clearTimeout(this.loginWatch.timer);
222
+ clearTimeout(this.loginWatch.deadline);
223
+ this.loginWatch = undefined;
224
+ }
225
+ login.abort.abort();
226
+ this.client.forgetQr?.(login.qrcode);
227
+ login.url = '';
228
+ login.qrDataUrl = '';
229
+ login.qrcode = '';
230
+ }
231
+ expireLogin(deadlineElapsed = false) {
232
+ if (this.broken)
233
+ return; // Preserve the restart diagnostic until disposal.
234
+ const login = this.login;
235
+ if (login && activeLoginStatus(login.status) && (deadlineElapsed || this.now() >= login.started + LOGIN_LIFETIME)) {
236
+ this.commit(d => { d.revision++; });
237
+ login.status = 'expired';
238
+ this.endLogin(login);
239
+ this.message = '二维码已过期,请重新生成。';
240
+ }
241
+ }
242
+ /** The node owns a requested login, even while both control pages are closed.
243
+ * Only read its result, for this attempt's bounded lifetime; never restart a
244
+ * login, resend a verification code, or enable message receiving here. */
245
+ watchLogin(login) {
246
+ const watch = { login, failures: 0 };
247
+ this.loginWatch = watch;
248
+ watch.deadline = setTimeout(() => {
249
+ if (this.loginWatch !== watch || this.closed)
250
+ return;
251
+ // The timer enforces the bound even if the computer clock moves back.
252
+ try {
253
+ this.expireLogin(true);
254
+ }
255
+ catch {
256
+ this.failLoginStorage(login);
257
+ }
258
+ }, Math.max(0, login.started + LOGIN_LIFETIME - this.now()));
259
+ watch.deadline.unref?.();
260
+ this.scheduleLoginWatch(login);
261
+ }
262
+ failLoginStorage(login) {
263
+ // Do not turn a background persistence exception into an unhandled timer
264
+ // rejection or an apparently successful binding. Preserve the old file.
265
+ this.broken = true;
266
+ this.state = 'error';
267
+ login.status = 'error';
268
+ this.endLogin(login);
269
+ this.message = '绑定记录未能保存,已停止接入。请核对本地记录后重启组件。';
270
+ }
271
+ scheduleLoginWatch(login) {
272
+ const watch = this.loginWatch;
273
+ if (!watch || watch.login !== login || watch.timer || watch.task || this.closed || this.broken
274
+ || this.login !== login || login.abort.signal.aborted || !activeLoginStatus(login.status)
275
+ || login.status === 'need_verifycode')
276
+ return;
277
+ const delay = Math.min(15_000, 1000 * 2 ** Math.min(watch.failures, 4));
278
+ watch.timer = setTimeout(() => {
279
+ watch.timer = undefined;
280
+ if (this.loginWatch !== watch || this.closed || login.abort.signal.aborted
281
+ || !activeLoginStatus(login.status) || login.status === 'need_verifycode')
282
+ return;
283
+ // The existing single-flight lane also handles passive UI reads and
284
+ // explicit verification. Closing a UI cannot abort this node-owned read.
285
+ watch.task = this.control({ action: 'login.poll', loginId: login.id }, this.lifetime.signal)
286
+ .then(() => { watch.failures = 0; })
287
+ .catch(error => {
288
+ if (this.login !== login || login.abort.signal.aborted || this.closed || this.broken)
289
+ return;
290
+ // An explicit verification submission supersedes this passive read.
291
+ // Its cancellation is not a provider failure and must not cancel the
292
+ // still-pending code submission or erase its next verification state.
293
+ if (error.name === 'AbortError')
294
+ return;
295
+ try {
296
+ const retry = ['clawbot-network-error', 'clawbot-timeout', 'clawbot-http-error', 'clawbot-api-error']
297
+ .includes(error.code || '');
298
+ const message = retry ? LOGIN_RETRY_MESSAGE
299
+ : '绑定状态未能确认,请重新生成二维码。';
300
+ if (this.message !== message || !retry)
301
+ this.commit(d => { d.revision++; });
302
+ this.message = message;
303
+ if (retry)
304
+ watch.failures++;
305
+ else {
306
+ login.status = 'error';
307
+ this.endLogin(login);
308
+ }
309
+ }
310
+ catch {
311
+ this.failLoginStorage(login);
312
+ }
313
+ }).finally(() => {
314
+ watch.task = undefined;
315
+ this.scheduleLoginWatch(login);
316
+ });
317
+ }, delay);
318
+ watch.timer.unref?.();
319
+ }
320
+ async control(request, signal) {
321
+ if (!request || typeof request.action !== 'string')
322
+ fail('clawbot-invalid', '无效的 ClawBot 操作');
323
+ signal.throwIfAborted();
324
+ if (this.closed)
325
+ fail('clawbot-stopped', 'ClawBot 组件已停用');
326
+ if (request.action === 'status')
327
+ return this.snapshot();
328
+ if (request.action === 'sessions')
329
+ return { sessions: await this.host.listSessions(signal) };
330
+ this.checkOperational(request);
331
+ if (this.requiresAccess(request))
332
+ await this.authorize();
333
+ if (request.action === 'login.poll')
334
+ return this.mutate(request, signal);
335
+ const activeLogin = this.login;
336
+ if (request.action === 'login.cancel' && activeLogin && activeLogin.id === request.loginId)
337
+ activeLogin.abort.abort();
338
+ // Serializes control mutations but never a long-running DSH turn.
339
+ const task = this.commandQueue.then(() => this.mutate(request, signal));
340
+ this.commandQueue = task.catch(() => { });
341
+ return task;
342
+ }
343
+ async mutate(request, signal) {
344
+ signal.throwIfAborted();
345
+ this.lifetime.signal.throwIfAborted();
346
+ this.checkOperational(request);
347
+ if (this.requiresAccess(request))
348
+ this.currentAccess();
349
+ switch (request.action) {
350
+ case 'route': {
351
+ this.checkRevision(request);
352
+ if (!request.sessionId)
353
+ fail('clawbot-invalid', '请选择会话');
354
+ const found = await this.host.describeSession(request.sessionId, signal);
355
+ signal.throwIfAborted();
356
+ this.lifetime.signal.throwIfAborted();
357
+ this.checkOperational(request);
358
+ this.checkRevision(request);
359
+ this.currentAccess();
360
+ if (!found)
361
+ fail('clawbot-session-missing', '此会话已不可选择,请刷新列表。');
362
+ this.commit(d => { d.target = found; d.revision++; });
363
+ break;
364
+ }
365
+ case 'enabled': {
366
+ this.checkRevision(request);
367
+ if (typeof request.enabled !== 'boolean')
368
+ fail('clawbot-invalid', '请指定开启或暂停');
369
+ if (this.broken && !this.data.enabled)
370
+ return this.snapshot();
371
+ this.expireLogin();
372
+ if (request.enabled && this.login && activeLoginStatus(this.login.status))
373
+ fail('clawbot-login-active', '请先完成或取消微信绑定。');
374
+ if (request.enabled && !this.data.credentials)
375
+ fail('clawbot-unpaired', '请先绑定微信。');
376
+ if (request.enabled && this.data.authExpired)
377
+ fail('clawbot-auth-expired', '微信连接已失效,请重新绑定。');
378
+ if (request.enabled && !this.data.target)
379
+ fail('clawbot-target-required', '请先选择一个会话。');
380
+ const owner = request.enabled ? this.access?.identity?.() : undefined;
381
+ this.commit(d => { d.enabled = request.enabled; if (owner && !d.accessBinding)
382
+ d.accessBinding = owner; d.revision++; });
383
+ if (this.login && !activeLoginStatus(this.login.status))
384
+ this.login = undefined;
385
+ if (request.enabled) {
386
+ this.message = '';
387
+ this.resume();
388
+ }
389
+ else {
390
+ this.polling?.abort();
391
+ if (!this.broken)
392
+ this.message = '已暂停接入;DSH 中已经开始的任务不会被取消。';
393
+ }
394
+ break;
395
+ }
396
+ case 'login.start': {
397
+ if (request.expectedRevision !== undefined)
398
+ this.checkRevision(request);
399
+ this.expireLogin();
400
+ // Both control surfaces observe the same attempt. Merely opening the
401
+ // second surface must not rotate a QR that is already being scanned.
402
+ if (this.login && activeLoginStatus(this.login.status))
403
+ return this.snapshot();
404
+ if (this.data.enabled && this.data.credentials)
405
+ fail('clawbot-busy', '请先暂停接入,再重新绑定。');
406
+ // Repair the old enabled-without-credentials dead end without touching a
407
+ // running identity or changing the user's eventual start preference.
408
+ if (this.data.enabled)
409
+ this.commit(d => { d.enabled = false; d.revision++; });
410
+ const abort = new AbortController();
411
+ const owner = this.access?.identity?.();
412
+ const combined = AbortSignal.any([signal, this.lifetime.signal, abort.signal]);
413
+ const qr = await this.client.getQr(combined, this.data.credentials && !this.data.authExpired ? [this.data.credentials.token] : []);
414
+ try {
415
+ const qrDataUrl = await QRCode.toDataURL(qr.url, { width: 240, margin: 2 });
416
+ combined.throwIfAborted();
417
+ this.checkOperational(request);
418
+ this.currentAccess();
419
+ if (owner && !this.sameOwner(owner, this.access.identity()))
420
+ this.accessFailure(new Error('节点或账号已变化,请重新开始绑定。'));
421
+ this.commit(d => { d.revision++; });
422
+ this.login = { id: randomUUID(), qrcode: qr.qrcode, url: qr.url, qrDataUrl, status: 'wait', started: this.now(), abort, ...(owner ? { owner } : {}) };
423
+ this.watchLogin(this.login);
424
+ }
425
+ catch (error) {
426
+ this.client.forgetQr?.(qr.qrcode);
427
+ throw error;
428
+ }
429
+ this.message = '请用微信扫一扫扫描电脑上的二维码,并在微信中确认。';
430
+ break;
431
+ }
432
+ case 'login.poll': {
433
+ this.expireLogin();
434
+ const login = this.login;
435
+ if (!login || login.id !== request.loginId)
436
+ fail('clawbot-login-stale', '绑定已取消或更新,请刷新。');
437
+ if (!activeLoginStatus(login.status))
438
+ return this.snapshot();
439
+ const previous = this.loginPoll;
440
+ if (previous && previous.login === login) {
441
+ if (request.verifyCode === undefined)
442
+ return this.snapshot();
443
+ if (previous.verifying)
444
+ fail('clawbot-login-busy', '微信确认码仍在提交,请稍候核对或取消绑定;不要重复提交。');
445
+ previous.abort.abort();
446
+ }
447
+ const polling = { login: login, abort: new AbortController(), verifying: request.verifyCode !== undefined };
448
+ this.loginPoll = polling;
449
+ const combined = AbortSignal.any([signal, this.lifetime.signal, login.abort.signal, polling.abort.signal]);
450
+ try {
451
+ const result = await this.client.pollQr(login.qrcode, combined, request.verifyCode);
452
+ combined.throwIfAborted();
453
+ if (this.login !== login)
454
+ return this.snapshot();
455
+ await this.authorize();
456
+ combined.throwIfAborted();
457
+ const finish = this.commandQueue.then(() => { combined.throwIfAborted(); this.completeLogin(login, result); });
458
+ this.commandQueue = finish.catch(() => { });
459
+ await finish;
460
+ }
461
+ finally {
462
+ if (this.loginPoll === polling)
463
+ this.loginPoll = undefined;
464
+ this.scheduleLoginWatch(login);
465
+ }
466
+ break;
467
+ }
468
+ case 'login.cancel': {
469
+ if (this.login && (!request.loginId || this.login.id !== request.loginId))
470
+ fail('clawbot-login-stale', '绑定状态已更新,请刷新。');
471
+ if (this.login) {
472
+ this.commit(d => { d.revision++; });
473
+ this.endLogin(this.login);
474
+ }
475
+ this.login = undefined;
476
+ if (!this.broken)
477
+ this.message = '';
478
+ break;
479
+ }
480
+ case 'disconnect': {
481
+ this.checkRevision(request);
482
+ this.expireLogin();
483
+ if (this.login && activeLoginStatus(this.login.status))
484
+ fail('clawbot-login-active', '请先取消正在进行的绑定。');
485
+ if (this.data.enabled)
486
+ fail('clawbot-busy', '请先暂停接入,再移除本地绑定。');
487
+ if (this.data.receipts.some(r => !terminal(r)) && request.abandonPending !== true)
488
+ fail('clawbot-pending', '仍有待处理或待发消息;须明确确认停止这些渠道消息后才能解绑。电脑上已开始的任务不会被取消。');
489
+ this.commit(d => {
490
+ for (const r of d.receipts)
491
+ if (!terminal(r)) {
492
+ r.state = 'uncertain';
493
+ r.input = '';
494
+ r.output = undefined;
495
+ r.parts = undefined;
496
+ r.contextToken = '';
497
+ }
498
+ d.credentials = null;
499
+ d.accessBinding = undefined;
500
+ d.authExpired = false;
501
+ d.target = null;
502
+ d.cursor = '';
503
+ d.revision++;
504
+ });
505
+ if (this.login) {
506
+ this.endLogin(this.login);
507
+ this.login = undefined;
508
+ }
509
+ this.message = '已移除此节点的本地绑定。未完成的渠道消息已停止续发;电脑上已开始的任务仍会继续。这不是腾讯账号端的撤销授权。';
510
+ break;
511
+ }
512
+ default: fail('clawbot-invalid', '不支持的 ClawBot 操作');
513
+ }
514
+ return this.snapshot();
515
+ }
516
+ completeLogin(login, result) {
517
+ this.lifetime.signal.throwIfAborted();
518
+ this.checkOperational();
519
+ this.currentAccess();
520
+ this.expireLogin();
521
+ if (this.login !== login || login.abort.signal.aborted)
522
+ return;
523
+ if (this.data.enabled)
524
+ fail('clawbot-busy', '请先暂停接入,再完成绑定。');
525
+ if (result.credentials) {
526
+ if (this.data.credentials && this.data.credentials.userId !== result.credentials.userId) {
527
+ this.commit(d => { d.revision++; });
528
+ this.endLogin(login);
529
+ this.login = undefined;
530
+ this.message = '此次确认的是另一个微信账号。未替换原绑定;如需换号,请先明确移除本地绑定。';
531
+ fail('clawbot-different-user', this.message);
532
+ }
533
+ if (this.data.credentials && this.data.credentials.accountId !== result.credentials.accountId
534
+ && this.data.receipts.some(r => !terminal(r))) {
535
+ this.commit(d => { d.revision++; });
536
+ this.endLogin(login);
537
+ this.login = undefined;
538
+ this.message = '原连接仍有未完成的消息,未替换本地绑定。请先处理原消息,或确认停止这些消息后移除绑定。';
539
+ fail('clawbot-pending', this.message);
540
+ }
541
+ this.commit(d => {
542
+ if (d.credentials?.accountId !== result.credentials.accountId) {
543
+ d.cursor = '';
544
+ }
545
+ d.credentials = result.credentials;
546
+ if (login.owner)
547
+ d.accessBinding = login.owner;
548
+ d.authExpired = false;
549
+ d.revision++;
550
+ });
551
+ this.endLogin(login);
552
+ this.login = undefined;
553
+ this.message = '微信已绑定。选择接入会话并启用私聊接入后即可使用。';
554
+ }
555
+ else if (result.status === 'binded_redirect' && this.data.credentials) {
556
+ this.commit(d => { if (login.owner && !d.accessBinding)
557
+ d.accessBinding = login.owner; d.revision++; });
558
+ this.endLogin(login);
559
+ this.login = undefined;
560
+ this.message = this.data.authExpired ? '原微信连接已失效,请重新绑定。'
561
+ : '微信确认本节点已有绑定。选择会话并开启接入后即可使用。';
562
+ }
563
+ else {
564
+ // Only an actual successful provider read can clear our transient retry
565
+ // notice. A coalesced UI snapshot never reaches this method, and unrelated
566
+ // diagnostics remain intact when the provider has no new message.
567
+ const message = result.message || (this.message === LOGIN_RETRY_MESSAGE ? '' : this.message);
568
+ if (login.status !== result.status || this.message !== message)
569
+ this.commit(d => { d.revision++; });
570
+ login.status = result.status;
571
+ if (!activeLoginStatus(result.status))
572
+ this.endLogin(login);
573
+ this.message = message;
574
+ }
575
+ }
576
+ resume() {
577
+ if (this.closed || this.broken || !this.data.enabled || !this.data.credentials || this.data.authExpired)
578
+ return;
579
+ if (!this.pollTask) {
580
+ const controller = new AbortController();
581
+ this.polling = controller;
582
+ this.pollTask = this.poll(controller.signal).catch(() => {
583
+ if (!this.closed && !controller.signal.aborted) {
584
+ this.broken = true;
585
+ this.state = 'error';
586
+ this.message = 'ClawBot 接入已暂停,请核对本地记录和连接。';
587
+ }
588
+ }).finally(() => {
589
+ this.pollTask = undefined;
590
+ if (this.polling === controller)
591
+ this.polling = undefined;
592
+ if (controller.signal.aborted && !this.closed && this.data.enabled)
593
+ this.resume();
594
+ });
595
+ }
596
+ this.work();
597
+ }
598
+ async delay(ms, signal) {
599
+ signal.throwIfAborted();
600
+ await new Promise(resolve => {
601
+ const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); };
602
+ const timer = setTimeout(finish, ms);
603
+ timer.unref?.();
604
+ signal.addEventListener('abort', finish, { once: true });
605
+ });
606
+ }
607
+ async poll(signal) {
608
+ let failures = 0;
609
+ while (!signal.aborted && !this.closed && this.data.enabled && this.data.credentials) {
610
+ try {
611
+ await this.authorize();
612
+ signal.throwIfAborted();
613
+ this.state = 'connecting';
614
+ const updates = await this.client.getUpdates(this.data.credentials, this.data.cursor, signal);
615
+ signal.throwIfAborted();
616
+ // A normal long-poll may span the short authority lease. Revalidate
617
+ // before accepting its data; never extend stale cached permission.
618
+ await this.authorize();
619
+ signal.throwIfAborted();
620
+ // Durably accept the entire batch with its cursor. Never acknowledge a
621
+ // batch before enqueueing it or after only accepting part of it.
622
+ this.ingest(updates.messages, updates.cursor);
623
+ failures = 0;
624
+ this.state = 'ready';
625
+ this.message = '';
626
+ this.work();
627
+ if (!updates.messages.length)
628
+ await this.delay(500, signal);
629
+ }
630
+ catch (error) {
631
+ if (signal.aborted || this.closed)
632
+ break;
633
+ failures++;
634
+ const code = error.code;
635
+ this.state = 'error';
636
+ this.message = pollErrorMessage(code);
637
+ if (code === 'clawbot-auth-expired' || code === 'clawbot-capacity') {
638
+ this.commit(d => { d.enabled = false; if (code === 'clawbot-auth-expired')
639
+ d.authExpired = true; d.revision++; });
640
+ break;
641
+ }
642
+ await this.delay(Math.min(60_000, 2000 * 2 ** Math.min(failures - 1, 5)), signal);
643
+ }
644
+ }
645
+ }
646
+ /** Exposed for deterministic contract testing, not a public RPC. */
647
+ ingest(messages, cursor) {
648
+ this.currentAccess();
649
+ if (!this.data.credentials)
650
+ fail('clawbot-unpaired', '尚未绑定微信');
651
+ this.commit(d => {
652
+ d.receipts = d.receipts.filter(r => !terminal(r) || this.now() - r.createdAt < RETENTION);
653
+ const seen = new Set(d.receipts.map(r => r.id));
654
+ for (const m of messages) {
655
+ // Binding user identity is a provider identity, never a mini OpenID.
656
+ if (m.userId !== d.credentials.userId)
657
+ continue;
658
+ const id = createHash('sha256').update(`${d.credentials.accountId}\0${m.userId}\0${m.id}`).digest('hex');
659
+ if (seen.has(id))
660
+ continue;
661
+ if (d.receipts.length >= MAX_RECEIPTS)
662
+ fail('clawbot-capacity', 'ClawBot 收件记录已满');
663
+ seen.add(id);
664
+ const invalid = m.unsupported || (!m.text.trim() && !m.media?.length) || m.text.length > MAX_INPUT;
665
+ const receipt = { id, userId: m.userId, contextToken: m.contextToken, input: invalid ? '' : m.text,
666
+ target: d.target && { ...d.target }, revision: d.revision, createdAt: this.now(), state: invalid ? 'ready' : 'pending',
667
+ ...(invalid ? { output: m.unsupportedReason || '这条消息暂不支持或超过接收上限,未发送到会话。请使用文字、图片或文件,单个附件不超过 10 MB、每条最多 6 个;文字最多 8000 字符。' }
668
+ : m.media?.length ? { media: structuredClone(m.media) } : {}) };
669
+ if (!invalid && !isCommand(receipt) && d.receipts.some(parent => this.nativeAttention.has(parent.id)
670
+ && parent.state === 'running' && sameTarget(receipt, parent)))
671
+ holdInput(receipt);
672
+ d.receipts.push(receipt);
673
+ }
674
+ d.cursor = cursor;
675
+ });
676
+ }
677
+ work() {
678
+ if (this.closed || this.broken || !this.data.enabled)
679
+ return;
680
+ const failed = () => { this.broken = true; this.state = 'error'; this.message = '本地记录保存失败,接入已停止,请核对原会话后重启组件。'; this.polling?.abort(); };
681
+ if (!this.worker && this.data.receipts.some(r => r.state === 'pending' && !isCommand(r)))
682
+ this.worker = this.process(false).catch(failed).finally(() => { this.worker = undefined; this.work(); });
683
+ if (!this.commandWorker && this.data.receipts.some(r => r.state === 'pending' && isCommand(r)))
684
+ this.commandWorker = this.process(true).catch(failed).finally(() => { this.commandWorker = undefined; this.work(); });
685
+ this.deliver();
686
+ }
687
+ update(id, patch) {
688
+ this.commit(d => { const r = d.receipts.find(r => r.id === id); if (r)
689
+ Object.assign(r, patch); });
690
+ }
691
+ async command(r) {
692
+ this.currentAccess();
693
+ const text = r.input.trim();
694
+ if (!isCommand(r))
695
+ return null;
696
+ if (text === '/帮助' || text === '/help')
697
+ return '/会话:列出可接入的会话\n/切换 编号:切换接入会话\n/状态:查看当前接入\n普通文字直接发送到接入会话;问答和审批请在小程序或电脑处理。';
698
+ if (/^\/回答(?:\s|$)/.test(text))
699
+ return '请在小程序或电脑的原生卡片回答问题。本条消息未发送到会话。';
700
+ if (text === '/状态')
701
+ return `接入会话:${this.data.target?.title || '尚未选择'}。私聊接入${this.data.enabled ? '已启用' : '已暂停'}。`;
702
+ if (text === '/会话') {
703
+ const sessions = await this.host.listSessions(this.lifetime.signal);
704
+ return sessions.length ? sessions.slice(0, 40).map(s => `${this.shortId(s.id)} · ${s.title}`).join('\n') + '\n发送 /切换 编号。' : '当前节点没有可选择的普通会话,请先在 DSH 新建会话。';
705
+ }
706
+ const match = text.match(/^\/切换\s+(\S+)$/);
707
+ if (match) {
708
+ const sessions = await this.host.listSessions(this.lifetime.signal);
709
+ const found = sessions.filter(s => this.shortId(s.id) === match[1]);
710
+ if (found.length !== 1)
711
+ return '编号不存在或不唯一,请发送 /会话 获取当前编号。';
712
+ const target = await this.host.describeSession(found[0].id, this.lifetime.signal);
713
+ // Commands and owner control use the same mutation queue. Already received
714
+ // messages keep their own captured target, even if still waiting to run.
715
+ const operation = this.commandQueue.then(() => {
716
+ this.lifetime.signal.throwIfAborted();
717
+ this.checkOperational();
718
+ this.currentAccess();
719
+ if (this.data.receipts.find(item => item.id === r.id)?.state !== 'running')
720
+ return;
721
+ this.commit(d => { d.target = target; d.revision++; });
722
+ });
723
+ this.commandQueue = operation.catch(() => { });
724
+ await operation;
725
+ return `已接到「${found[0].title}」。现在发送的文字将进入此会话;先前已接收的消息仍属于原会话。`;
726
+ }
727
+ return '请发送 /帮助 查看指令用法。';
728
+ }
729
+ shortId(id) { return createHash('sha256').update(id).digest('hex').slice(0, 8); }
730
+ /** Notices reuse the same durable outbox and delivery fences as final replies.
731
+ * At most two per request; no new sender, polling loop or replay permission. */
732
+ attention(r, kind) {
733
+ if (this.closed || this.broken || this.data.receipts.find(item => item.id === r.id)?.state !== 'running')
734
+ return;
735
+ // Passive notification only. Keep potentially ambiguous replies out of the
736
+ // ordinary prompt queue until this observed turn ends; never answer a tool.
737
+ this.nativeAttention.add(r.id);
738
+ const id = attentionId(r.id, kind);
739
+ if (this.data.receipts.some(item => item.id === id))
740
+ return;
741
+ if (this.data.receipts.length >= MAX_RECEIPTS)
742
+ fail('clawbot-capacity', '本机收件记录已满,请在原会话处理并查看结果。');
743
+ const action = kind === 'approval' ? '确认审批' : '回答问题';
744
+ this.commit(d => {
745
+ holdPending(d, r);
746
+ d.receipts.push({ id, userId: r.userId, contextToken: r.contextToken, input: '',
747
+ target: r.target, revision: r.revision, createdAt: this.now(), state: 'ready',
748
+ output: `请在小程序或电脑${action},当前任务未取消。` });
749
+ });
750
+ this.deliver();
751
+ }
752
+ async process(commands) {
753
+ while (!this.closed && !this.broken && this.data.enabled && this.data.credentials) {
754
+ const r = this.data.receipts.find(r => r.state === 'pending' && isCommand(r) === commands);
755
+ if (!r)
756
+ return;
757
+ try {
758
+ await this.authorize();
759
+ }
760
+ catch {
761
+ return;
762
+ }
763
+ if (this.closed || this.broken || !this.data.enabled)
764
+ return;
765
+ if (this.data.receipts.find(item => item.id === r.id)?.state === 'pending') {
766
+ this.update(r.id, { state: 'running' });
767
+ let output = '';
768
+ const mediaParts = [];
769
+ const notices = [];
770
+ try {
771
+ const command = await this.command(r);
772
+ if (this.broken)
773
+ return;
774
+ if (command !== null)
775
+ output = command;
776
+ else if (!r.target)
777
+ output = '尚未选择接入会话。请发送 /会话,或在电脑插件/小程序中选择。';
778
+ else if (!Number.isSafeInteger(r.target.generation))
779
+ output = '接入会话需要重新选择,未派发此次输入。';
780
+ else {
781
+ this.currentAccess();
782
+ const accountId = this.data.credentials.accountId;
783
+ const checkAdmission = () => {
784
+ this.currentAccess();
785
+ if (this.closed || this.broken || !this.data.enabled || this.data.credentials?.accountId !== accountId
786
+ || this.data.credentials?.userId !== r.userId || this.data.receipts.find(item => item.id === r.id)?.state !== 'running')
787
+ fail('clawbot-not-submitted', '接入已暂停或绑定已变化,本条消息未发送到会话。');
788
+ };
789
+ const attachments = [];
790
+ let totalBytes = 0;
791
+ if (r.media && (r.media.length > MEDIA_MAX_ITEMS || typeof this.client.downloadMedia !== 'function'))
792
+ fail('clawbot-media-unavailable', '当前接入暂不能读取此附件,本条消息未发送。');
793
+ for (const media of r.media || []) {
794
+ await this.authorize();
795
+ checkAdmission();
796
+ let downloaded;
797
+ try {
798
+ downloaded = await this.client.downloadMedia(media, this.lifetime.signal);
799
+ }
800
+ catch {
801
+ fail('clawbot-media-unavailable', '附件未能完整接收或格式暂不支持,本条消息未发送。请在小程序或电脑检查后按需重发。');
802
+ }
803
+ checkAdmission();
804
+ totalBytes += downloaded.data.byteLength;
805
+ if (!downloaded.data.byteLength || downloaded.data.byteLength > MEDIA_MAX_BYTES || totalBytes > 30 * 1024 * 1024)
806
+ fail('clawbot-media-too-large', '单个附件不能超过 10 MB,一条消息的附件总计不能超过 30 MB;本条消息未发送。');
807
+ attachments.push({ kind: media.kind, ...downloaded });
808
+ }
809
+ // Crash after native admission must not re-download and replay.
810
+ if (r.media)
811
+ this.update(r.id, { media: undefined });
812
+ checkAdmission();
813
+ const result = await this.host.run({ sessionId: r.target.id, expectedGeneration: r.target.generation, requestId: `clawbot-${r.id}`, text: r.input,
814
+ ...(attachments.length ? { attachments } : {}), checkAdmission }, this.lifetime.signal, kind => this.attention(r, kind));
815
+ if (typeof result.text !== 'string' || Buffer.byteLength(result.text) > REPLY_TEXT_MAX_BYTES)
816
+ fail('clawbot-output-too-large', '回复超过本地传输上限,请在原会话查看。');
817
+ output = result.text;
818
+ if (result.notices?.length) {
819
+ if (result.notices.length <= REPLY_MAX_NOTICES - 1 && result.notices.every(notice => typeof notice === 'string' && notice.length > 0 && Buffer.byteLength(notice) <= REPLY_NOTICE_MAX_BYTES))
820
+ notices.push(...result.notices);
821
+ else
822
+ notices.push('部分内容未能回传,请在原会话查看。');
823
+ }
824
+ if (result.attachments?.length) {
825
+ // Read-only native deliverables arrive as one bounded snapshot.
826
+ // Upload is separate from sending: no live host paths, bytes or
827
+ // extra filesystem reader are persisted in the channel ledger.
828
+ const sendingCredentials = this.data.credentials;
829
+ const checkOutput = () => {
830
+ checkAdmission();
831
+ if (!sendingCredentials || this.data.credentials?.token !== sendingCredentials.token)
832
+ fail('clawbot-media-unavailable', '微信绑定已变化,未继续准备附件。');
833
+ };
834
+ let omitted = result.attachments.length > MEDIA_MAX_ITEMS, totalOutputBytes = 0;
835
+ for (const part of result.attachments.slice(0, MEDIA_MAX_ITEMS)) {
836
+ try {
837
+ // These bounded in-memory/output limits are local policy. A
838
+ // failed file must not suppress later independent images.
839
+ if (mediaParts.length >= MEDIA_MAX_ITEMS || !(part.data instanceof Uint8Array) || !part.data.byteLength
840
+ || part.data.byteLength > MEDIA_MAX_BYTES || totalOutputBytes + part.data.byteLength > 30 * 1024 * 1024
841
+ || typeof this.client.prepareMedia !== 'function')
842
+ throw Error('unsupported output');
843
+ await this.authorize();
844
+ checkOutput();
845
+ const prepared = await this.client.prepareMedia(sendingCredentials, { userId: r.userId, ...part }, this.lifetime.signal, checkOutput);
846
+ checkOutput();
847
+ mediaParts.push(prepared);
848
+ totalOutputBytes += part.data.byteLength;
849
+ }
850
+ catch {
851
+ omitted = true;
852
+ // Preserve the completed text when paused/revoked, but never
853
+ // perform another upload after its authority was withdrawn.
854
+ try {
855
+ checkOutput();
856
+ }
857
+ catch {
858
+ break;
859
+ }
860
+ }
861
+ }
862
+ if (omitted)
863
+ notices.push('部分附件未能回传,请在原会话查看。');
864
+ }
865
+ }
866
+ }
867
+ catch (error) {
868
+ if (this.closed || this.broken)
869
+ return;
870
+ // Do not leak host paths, tool arguments or arbitrary exception data.
871
+ output = error instanceof ClawbotError ? error.message : '未能确认本轮回复,请在原会话查看;不会自动重复提交。';
872
+ }
873
+ finally {
874
+ this.nativeAttention.delete(r.id);
875
+ }
876
+ if (this.closed || this.broken)
877
+ return;
878
+ // Explicit disconnect may seal the old account's in-flight receipt. A late
879
+ // host result must not revive it and send through a different bot.
880
+ if (this.data.receipts.find(item => item.id === r.id)?.state !== 'running')
881
+ continue;
882
+ const parts = [...splitText(output), ...mediaParts, ...notices.flatMap(splitText)];
883
+ this.commit(d => {
884
+ sealUnsentAttention(d, r.id);
885
+ Object.assign(d.receipts.find(item => item.id === r.id), parts.length
886
+ ? { state: 'ready', output, input: '', parts, sentParts: 0 }
887
+ : { state: 'done', output: undefined, input: '', parts: undefined, sentParts: undefined, contextToken: '' });
888
+ });
889
+ this.deliver();
890
+ }
891
+ }
892
+ }
893
+ deliver() {
894
+ if (this.deliveryWorker || this.closed || this.broken || !this.data.enabled || !this.data.receipts.some(r => r.state === 'ready'))
895
+ return;
896
+ this.deliveryWorker = this.drain().catch(() => { this.broken = true; this.state = 'error'; this.message = '发送记录保存失败,已停止接入,请核对原会话。'; this.polling?.abort(); })
897
+ .finally(() => { this.deliveryWorker = undefined; this.deliver(); });
898
+ }
899
+ async drain() {
900
+ while (!this.closed && !this.broken && this.data.enabled && this.data.credentials) {
901
+ const current = this.data.receipts.find(item => item.state === 'ready');
902
+ if (!current)
903
+ return;
904
+ const r = current;
905
+ const parts = current.parts || splitText(current.output || '');
906
+ if (!parts.length) {
907
+ this.update(r.id, { state: 'done', output: undefined, input: '', contextToken: '' });
908
+ continue;
909
+ }
910
+ for (let index = current.sentParts || 0; index < parts.length; index++) {
911
+ try {
912
+ await this.authorize();
913
+ }
914
+ catch {
915
+ return;
916
+ }
917
+ if (this.closed || this.broken || !this.data.enabled)
918
+ return;
919
+ // Authorization may yield while completion retires an unsent notice or
920
+ // disconnect seals the old binding. Never revive the captured receipt.
921
+ const latest = this.data.receipts.find(item => item.id === r.id);
922
+ if (latest?.state !== 'ready' || (latest.sentParts || 0) !== index)
923
+ break;
924
+ this.update(r.id, { state: 'sending', parts, sentParts: index });
925
+ const sendingCredentials = this.data.credentials;
926
+ try {
927
+ const part = parts[index], target = { userId: r.userId, contextToken: r.contextToken, clientId: `dsh-${r.id.slice(0, 32)}-${index}` };
928
+ if (typeof part === 'string')
929
+ await this.client.sendText(sendingCredentials, { ...target, text: part }, this.lifetime.signal);
930
+ else
931
+ await this.client.sendMedia(sendingCredentials, { ...target, media: part }, this.lifetime.signal);
932
+ }
933
+ catch (error) {
934
+ if (this.closed || this.broken)
935
+ return;
936
+ if (this.data.receipts.find(item => item.id === r.id)?.state !== 'sending')
937
+ break;
938
+ this.update(r.id, { state: 'uncertain', input: '', output: undefined, parts: undefined, contextToken: '' });
939
+ // A paused send may finish after same-account reauthentication. Its
940
+ // old token rejection is not evidence that the replacement is invalid.
941
+ if (error.code === 'clawbot-auth-expired'
942
+ && this.data.credentials?.accountId === sendingCredentials.accountId
943
+ && this.data.credentials?.token === sendingCredentials.token) {
944
+ this.commit(d => { d.enabled = false; d.authExpired = true; d.revision++; });
945
+ this.polling?.abort();
946
+ this.message = '微信连接已失效,请重新绑定。未确认送达的回复请在原会话查看。';
947
+ }
948
+ else
949
+ this.message = '有回复发送结果不确定,未自动重发;完整结果仍在原 DSH 会话。';
950
+ break;
951
+ }
952
+ if (this.closed || this.broken)
953
+ return;
954
+ if (this.data.receipts.find(item => item.id === r.id)?.state !== 'sending')
955
+ break;
956
+ this.update(r.id, { state: index === parts.length - 1 ? 'done' : 'ready', sentParts: index + 1,
957
+ ...(index === parts.length - 1 ? { output: undefined, parts: undefined, contextToken: '' } : {}) });
958
+ }
959
+ }
960
+ }
961
+ async dispose() {
962
+ if (this.closed)
963
+ return;
964
+ const loginTask = this.loginWatch?.task;
965
+ this.closed = true;
966
+ this.lifetime.abort();
967
+ this.polling?.abort();
968
+ if (this.login)
969
+ this.endLogin(this.login);
970
+ this.login = undefined;
971
+ await Promise.allSettled([this.worker, this.commandWorker, this.deliveryWorker, this.pollTask, this.commandQueue, loginTask]);
972
+ this.storage.close();
973
+ }
974
+ }