@xmanrui/dsh-im 2.4.0 → 2.6.0
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/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +322 -109
- package/lib/index.js +218 -211
- package/package.json +1 -1
- package/plugin-src/client/channel-card-meta.js +36 -1
- package/plugin-src/client/channels/dingtalk/api.js +2 -0
- package/plugin-src/client/channels/dingtalk/index.js +9 -1
- package/plugin-src/client/channels/feishu/api.js +3 -0
- package/plugin-src/client/channels/feishu/index.js +9 -1
- package/plugin-src/client/channels/qq/api.js +2 -0
- package/plugin-src/client/channels/qq/index.js +9 -1
- package/plugin-src/client/channels/shared/token-api.js +2 -0
- package/plugin-src/client/channels/shared/token-channel.js +9 -1
- package/plugin-src/client/channels/wecom/api.js +2 -0
- package/plugin-src/client/channels/wecom/index.js +9 -1
- package/plugin-src/client/channels/weixin/api.js +2 -10
- package/plugin-src/client/channels/weixin/index.js +9 -5
- package/plugin-src/client/channels/whatsapp/api.js +2 -0
- package/plugin-src/client/channels/whatsapp/index.js +9 -1
- package/plugin-src/client/i18n.js +4 -0
- package/plugin-src/client/index.js +16 -2
- package/plugin-src/client/last-message-error.js +17 -0
- package/plugin-src/client/styles.js +5 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +3 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +102 -6
- package/src/channels/dingtalk/dingtalk-bridge.mjs +247 -38
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +2 -2
- package/src/channels/dingtalk/dingtalk-controller.mjs +2 -0
- package/src/channels/discord/discord-api.mjs +35 -0
- package/src/channels/discord/discord-bridge.mjs +1 -0
- package/src/channels/discord/discord-runtime.mjs +26 -0
- package/src/channels/feishu/bridge.mjs +115 -69
- package/src/channels/feishu/multi-bot-controller.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +83 -28
- package/src/channels/qq/qq-controller.mjs +2 -0
- package/src/channels/shared/harness-client.mjs +40 -4
- package/src/channels/shared/i18n-en/shared-a.mjs +77 -0
- package/src/channels/shared/message-failure.mjs +244 -0
- package/src/channels/shared/semantic/artifact-delivery.mjs +9 -2
- package/src/channels/shared/status-reaction.mjs +107 -0
- package/src/channels/shared/text-harness-bridge.mjs +123 -66
- package/src/channels/shared/token-bot-controller.mjs +2 -0
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +28 -0
- package/src/channels/slack/slack-bridge.mjs +5 -0
- package/src/channels/slack/slack-controller.mjs +2 -0
- package/src/channels/slack/slack-runtime.mjs +24 -0
- package/src/channels/telegram/telegram-api.mjs +9 -0
- package/src/channels/telegram/telegram-bridge.mjs +1 -0
- package/src/channels/telegram/telegram-runtime.mjs +20 -0
- package/src/channels/wecom/wecom-bridge.mjs +49 -15
- package/src/channels/wecom/wecom-controller.mjs +2 -0
- package/src/channels/weixin/weixin-api.mjs +47 -0
- package/src/channels/weixin/weixin-bridge.mjs +261 -43
- package/src/channels/weixin/weixin-controller.mjs +2 -15
- package/src/channels/weixin/weixin-runtime.mjs +1 -0
- package/src/channels/whatsapp/whatsapp-bridge.mjs +1 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +2 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +36 -0
|
@@ -49,10 +49,17 @@ import {
|
|
|
49
49
|
createDeliveryReceipt,
|
|
50
50
|
providerMessageIdsFor,
|
|
51
51
|
} from '../shared/semantic/delivery.mjs';
|
|
52
|
+
import {
|
|
53
|
+
channelDeliveryFailure,
|
|
54
|
+
clearLastMessageFailure,
|
|
55
|
+
messageFailureText,
|
|
56
|
+
setLastMessageFailure,
|
|
57
|
+
} from '../shared/message-failure.mjs';
|
|
52
58
|
import { t } from '../shared/i18n.mjs';
|
|
53
59
|
|
|
54
60
|
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
|
55
|
-
const
|
|
61
|
+
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
|
|
62
|
+
const TYPING_RETRY_DELAY_MS = 60_000;
|
|
56
63
|
|
|
57
64
|
const HELP_TEXT = () => [
|
|
58
65
|
t('微信已连接 DeepSeek Harness。'),
|
|
@@ -130,16 +137,6 @@ function canClaimInteractionReply(message, pending) {
|
|
|
130
137
|
&& nonEmptyString(extractWeixinText(message));
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR()) {
|
|
134
|
-
const diagnostic = imagePromptDiagnostic(error);
|
|
135
|
-
return {
|
|
136
|
-
code: diagnostic?.code ?? 'message-processing-failed',
|
|
137
|
-
reason: diagnostic?.reason ?? 'UNKNOWN',
|
|
138
|
-
message: diagnostic?.userMessage ?? userMessage,
|
|
139
|
-
at: Date.now(),
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
140
|
function artifactFailureText(fileName, error) {
|
|
144
141
|
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
|
|
145
142
|
switch (error?.code) {
|
|
@@ -186,6 +183,7 @@ export class WeixinHarnessBridge {
|
|
|
186
183
|
#logger;
|
|
187
184
|
#replyTimeoutMs;
|
|
188
185
|
#maxMessageChars;
|
|
186
|
+
#typingKeepaliveMs;
|
|
189
187
|
#signal;
|
|
190
188
|
#queues = new Map();
|
|
191
189
|
#pendingInteractions = new Map();
|
|
@@ -195,6 +193,14 @@ export class WeixinHarnessBridge {
|
|
|
195
193
|
#commandTasks = new Set();
|
|
196
194
|
#approvals;
|
|
197
195
|
#batchInputs = new BatchInputManager();
|
|
196
|
+
#typingTicket = null;
|
|
197
|
+
#typingTarget = null;
|
|
198
|
+
#typingTimer = null;
|
|
199
|
+
#typingGeneration = 0;
|
|
200
|
+
#typingTail = Promise.resolve();
|
|
201
|
+
#typingClosed = false;
|
|
202
|
+
#typingRetryAt = 0;
|
|
203
|
+
#typingTicketStale = false;
|
|
198
204
|
|
|
199
205
|
constructor({
|
|
200
206
|
api,
|
|
@@ -207,11 +213,15 @@ export class WeixinHarnessBridge {
|
|
|
207
213
|
logger = console,
|
|
208
214
|
replyTimeoutMs = 600_000,
|
|
209
215
|
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
|
216
|
+
typingKeepaliveMs = DEFAULT_TYPING_KEEPALIVE_MS,
|
|
210
217
|
signal,
|
|
211
218
|
}) {
|
|
212
219
|
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
|
|
213
220
|
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
|
|
214
221
|
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
|
222
|
+
if (!Number.isFinite(typingKeepaliveMs) || typingKeepaliveMs <= 0) {
|
|
223
|
+
throw new TypeError('typingKeepaliveMs must be a positive number');
|
|
224
|
+
}
|
|
215
225
|
this.#api = api;
|
|
216
226
|
this.#baseUrl = baseUrl;
|
|
217
227
|
this.#token = token;
|
|
@@ -222,6 +232,7 @@ export class WeixinHarnessBridge {
|
|
|
222
232
|
this.#logger = logger;
|
|
223
233
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
224
234
|
this.#maxMessageChars = maxMessageChars;
|
|
235
|
+
this.#typingKeepaliveMs = typingKeepaliveMs;
|
|
225
236
|
this.#signal = signal;
|
|
226
237
|
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
|
|
227
238
|
}
|
|
@@ -268,6 +279,7 @@ export class WeixinHarnessBridge {
|
|
|
268
279
|
return this.#finishBatchResult(
|
|
269
280
|
message,
|
|
270
281
|
messageId,
|
|
282
|
+
key,
|
|
271
283
|
sender,
|
|
272
284
|
contextToken,
|
|
273
285
|
runId,
|
|
@@ -294,9 +306,12 @@ export class WeixinHarnessBridge {
|
|
|
294
306
|
).catch((error) => {
|
|
295
307
|
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
|
296
308
|
this.#status.lastError = error?.message ?? String(error);
|
|
297
|
-
this.#status
|
|
298
|
-
this.#logger.error?.(
|
|
299
|
-
|
|
309
|
+
const failure = setLastMessageFailure(this.#status, error);
|
|
310
|
+
this.#logger.error?.(
|
|
311
|
+
`[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
|
|
312
|
+
error,
|
|
313
|
+
);
|
|
314
|
+
return this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
|
|
300
315
|
.catch(() => undefined);
|
|
301
316
|
}).finally(() => {
|
|
302
317
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -386,7 +401,7 @@ export class WeixinHarnessBridge {
|
|
|
386
401
|
return current;
|
|
387
402
|
}
|
|
388
403
|
|
|
389
|
-
#finishBatchResult(message, messageId, sender, contextToken, runId, result) {
|
|
404
|
+
#finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
|
|
390
405
|
let task;
|
|
391
406
|
task = Promise.resolve().then(async () => {
|
|
392
407
|
if (this.#state.hasSeen(messageId)) return;
|
|
@@ -394,16 +409,18 @@ export class WeixinHarnessBridge {
|
|
|
394
409
|
this.#status.messagesReceived += 1;
|
|
395
410
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
396
411
|
if (result.message) {
|
|
397
|
-
await this.#
|
|
412
|
+
await this.#sendOutOfBand(key, sender, result.message, contextToken, runId);
|
|
398
413
|
}
|
|
399
414
|
this.#status.lastError = null;
|
|
400
|
-
this.#status.lastMessageError = null;
|
|
401
415
|
}).catch(async (error) => {
|
|
402
416
|
if (this.#signal?.aborted) return;
|
|
403
417
|
this.#status.lastError = error?.message ?? String(error);
|
|
404
|
-
this.#status
|
|
405
|
-
this.#logger.error?.(
|
|
406
|
-
|
|
418
|
+
const failure = setLastMessageFailure(this.#status, error);
|
|
419
|
+
this.#logger.error?.(
|
|
420
|
+
`[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
|
|
421
|
+
error,
|
|
422
|
+
);
|
|
423
|
+
await this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
|
|
407
424
|
.catch(() => undefined);
|
|
408
425
|
}).finally(() => {
|
|
409
426
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -421,9 +438,15 @@ export class WeixinHarnessBridge {
|
|
|
421
438
|
)),
|
|
422
439
|
...this.#approvalTasks,
|
|
423
440
|
...this.#commandTasks,
|
|
441
|
+
this.#typingTail,
|
|
424
442
|
]);
|
|
425
443
|
}
|
|
426
444
|
|
|
445
|
+
async close() {
|
|
446
|
+
this.#typingClosed = true;
|
|
447
|
+
await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
|
|
448
|
+
}
|
|
449
|
+
|
|
427
450
|
async #processFastCommand(
|
|
428
451
|
message,
|
|
429
452
|
messageId,
|
|
@@ -454,10 +477,14 @@ export class WeixinHarnessBridge {
|
|
|
454
477
|
]);
|
|
455
478
|
}
|
|
456
479
|
for (const reply of result?.messages ?? [result?.message]) {
|
|
457
|
-
if (reply)
|
|
480
|
+
if (!reply) continue;
|
|
481
|
+
if (result?.stopped) {
|
|
482
|
+
await this.#send(sender, reply, contextToken, runId);
|
|
483
|
+
} else {
|
|
484
|
+
await this.#sendOutOfBand(key, sender, reply, contextToken, runId);
|
|
485
|
+
}
|
|
458
486
|
}
|
|
459
487
|
this.#status.lastError = null;
|
|
460
|
-
this.#status.lastMessageError = null;
|
|
461
488
|
}
|
|
462
489
|
|
|
463
490
|
async #process(message, key, {
|
|
@@ -483,6 +510,7 @@ export class WeixinHarnessBridge {
|
|
|
483
510
|
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
|
484
511
|
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
|
485
512
|
let batchSettled = batchSubmission === null;
|
|
513
|
+
let promptRecorded = false;
|
|
486
514
|
try {
|
|
487
515
|
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
|
488
516
|
const text = promptMessage.content;
|
|
@@ -537,12 +565,15 @@ export class WeixinHarnessBridge {
|
|
|
537
565
|
return;
|
|
538
566
|
}
|
|
539
567
|
|
|
540
|
-
const content = hasImages
|
|
541
|
-
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
542
|
-
: undefined;
|
|
543
568
|
let answer;
|
|
544
569
|
let artifacts = [];
|
|
570
|
+
await this.#startTyping(sender, contextToken);
|
|
545
571
|
try {
|
|
572
|
+
const content = hasImages
|
|
573
|
+
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
574
|
+
: undefined;
|
|
575
|
+
await this.#state.markSeen(messageId);
|
|
576
|
+
promptRecorded = true;
|
|
546
577
|
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
|
547
578
|
harness: this.#harness,
|
|
548
579
|
state: this.#state,
|
|
@@ -554,13 +585,17 @@ export class WeixinHarnessBridge {
|
|
|
554
585
|
timeoutMs: this.#replyTimeoutMs,
|
|
555
586
|
signal: this.#signal,
|
|
556
587
|
control: { owner: this, key },
|
|
588
|
+
onUpdate: () => this.#resumeTyping(key, sender, contextToken),
|
|
557
589
|
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
558
590
|
key,
|
|
559
591
|
actor: sender,
|
|
560
592
|
contextToken,
|
|
561
593
|
runId,
|
|
562
594
|
}),
|
|
563
|
-
onInteractionResolved: (resolution) =>
|
|
595
|
+
onInteractionResolved: async (resolution) => {
|
|
596
|
+
await this.#handleInteractionResolved(resolution);
|
|
597
|
+
await this.#resumeTyping(key, sender, contextToken);
|
|
598
|
+
},
|
|
564
599
|
files: promptMessage.files,
|
|
565
600
|
},
|
|
566
601
|
}));
|
|
@@ -570,6 +605,7 @@ export class WeixinHarnessBridge {
|
|
|
570
605
|
}
|
|
571
606
|
} finally {
|
|
572
607
|
await Promise.allSettled([
|
|
608
|
+
this.#stopTyping(),
|
|
573
609
|
this.#cancelPendingInteraction(key),
|
|
574
610
|
this.#approvals.closeRoute(key),
|
|
575
611
|
]);
|
|
@@ -586,7 +622,7 @@ export class WeixinHarnessBridge {
|
|
|
586
622
|
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
|
|
587
623
|
});
|
|
588
624
|
} catch (error) {
|
|
589
|
-
textDeliveryError = error;
|
|
625
|
+
textDeliveryError = channelDeliveryFailure(error);
|
|
590
626
|
}
|
|
591
627
|
const delivery = await this.#deliverArtifacts(
|
|
592
628
|
sender,
|
|
@@ -597,11 +633,16 @@ export class WeixinHarnessBridge {
|
|
|
597
633
|
textReceipt,
|
|
598
634
|
);
|
|
599
635
|
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
|
600
|
-
|
|
636
|
+
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
|
637
|
+
setLastMessageFailure(this.#status, textDeliveryError);
|
|
638
|
+
}
|
|
639
|
+
if (!promptRecorded) await this.#state.markSeen(messageId);
|
|
601
640
|
this.#status.messagesReplied += 1;
|
|
602
641
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
603
642
|
this.#status.lastError = null;
|
|
604
|
-
|
|
643
|
+
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
|
|
644
|
+
clearLastMessageFailure(this.#status);
|
|
645
|
+
}
|
|
605
646
|
return delivery.receipt;
|
|
606
647
|
} catch (error) {
|
|
607
648
|
let batchFailureMessage = null;
|
|
@@ -614,24 +655,32 @@ export class WeixinHarnessBridge {
|
|
|
614
655
|
batchSettled = true;
|
|
615
656
|
}
|
|
616
657
|
if (error?.code === 'turn-stopped') {
|
|
617
|
-
await this.#state.markSeen(messageId);
|
|
658
|
+
if (!promptRecorded) await this.#state.markSeen(messageId);
|
|
618
659
|
return;
|
|
619
660
|
}
|
|
620
661
|
if (this.#signal?.aborted) return;
|
|
621
662
|
this.#status.lastError = error?.message ?? String(error);
|
|
622
663
|
const userMessage = inboundFileUserMessage(error)
|
|
623
|
-
?? imagePromptUserMessage(error)
|
|
624
|
-
|
|
625
|
-
this.#status
|
|
626
|
-
|
|
664
|
+
?? imagePromptUserMessage(error);
|
|
665
|
+
const imageDiagnostic = imagePromptDiagnostic(error);
|
|
666
|
+
const failure = setLastMessageFailure(this.#status, error, {
|
|
667
|
+
userMessage,
|
|
668
|
+
reason: imageDiagnostic?.reason,
|
|
669
|
+
});
|
|
670
|
+
this.#logger.error?.(
|
|
671
|
+
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
|
|
672
|
+
error,
|
|
673
|
+
);
|
|
627
674
|
try {
|
|
628
675
|
await this.#send(
|
|
629
676
|
sender,
|
|
630
|
-
batchFailureMessage
|
|
677
|
+
batchFailureMessage
|
|
678
|
+
? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
|
|
679
|
+
: messageFailureText(failure),
|
|
631
680
|
contextToken,
|
|
632
681
|
runId,
|
|
633
682
|
);
|
|
634
|
-
await this.#state.markSeen(messageId);
|
|
683
|
+
if (!promptRecorded) await this.#state.markSeen(messageId);
|
|
635
684
|
} catch (sendError) {
|
|
636
685
|
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
|
|
637
686
|
}
|
|
@@ -741,7 +790,6 @@ export class WeixinHarnessBridge {
|
|
|
741
790
|
});
|
|
742
791
|
this.#clearPendingInteraction(key, pending.interactionId);
|
|
743
792
|
this.#status.lastError = null;
|
|
744
|
-
this.#status.lastMessageError = null;
|
|
745
793
|
} catch (error) {
|
|
746
794
|
if (this.#signal?.aborted) return;
|
|
747
795
|
if (error?.code === 'interaction-not-pending') {
|
|
@@ -935,20 +983,24 @@ export class WeixinHarnessBridge {
|
|
|
935
983
|
async #handleInteractionFailure(message, messageId, error) {
|
|
936
984
|
if (this.#signal?.aborted) return;
|
|
937
985
|
this.#status.lastError = error?.message ?? String(error);
|
|
938
|
-
this.#status
|
|
939
|
-
this.#logger.error?.(
|
|
986
|
+
const failure = setLastMessageFailure(this.#status, error);
|
|
987
|
+
this.#logger.error?.(
|
|
988
|
+
`[dsh-weixin] failed to process an interaction reply [${failure.referenceId}]:`,
|
|
989
|
+
error,
|
|
990
|
+
);
|
|
940
991
|
if (!this.#state.hasSeen(messageId)) {
|
|
941
992
|
await this.#state.markSeen(messageId).catch(() => undefined);
|
|
942
993
|
}
|
|
943
994
|
await this.#send(
|
|
944
995
|
nonEmptyString(message?.from_user_id),
|
|
945
|
-
|
|
996
|
+
messageFailureText(failure),
|
|
946
997
|
nonEmptyString(message?.context_token) ?? undefined,
|
|
947
998
|
nonEmptyString(message?.run_id) ?? undefined,
|
|
948
999
|
).catch(() => undefined);
|
|
949
1000
|
}
|
|
950
1001
|
|
|
951
1002
|
async #send(toUserId, text, contextToken, runId) {
|
|
1003
|
+
await this.#stopTyping();
|
|
952
1004
|
const providerMessageIds = [];
|
|
953
1005
|
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
|
954
1006
|
const result = await this.#api.sendText({
|
|
@@ -965,6 +1017,164 @@ export class WeixinHarnessBridge {
|
|
|
965
1017
|
return providerMessageIds;
|
|
966
1018
|
}
|
|
967
1019
|
|
|
1020
|
+
async #sendOutOfBand(key, toUserId, text, contextToken, runId) {
|
|
1021
|
+
try {
|
|
1022
|
+
return await this.#send(toUserId, text, contextToken, runId);
|
|
1023
|
+
} finally {
|
|
1024
|
+
if (this.#queues.has(key)) {
|
|
1025
|
+
await this.#resumeTyping(key, toUserId, contextToken);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
#queueTyping(operation) {
|
|
1031
|
+
const task = this.#typingTail
|
|
1032
|
+
.catch(() => undefined)
|
|
1033
|
+
.then(operation);
|
|
1034
|
+
this.#typingTail = task.catch(() => undefined);
|
|
1035
|
+
return task;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async #startTyping(toUserId, contextToken) {
|
|
1039
|
+
const target = nonEmptyString(toUserId);
|
|
1040
|
+
if (!target
|
|
1041
|
+
|| this.#typingClosed
|
|
1042
|
+
|| this.#signal?.aborted
|
|
1043
|
+
|| Date.now() < this.#typingRetryAt
|
|
1044
|
+
|| typeof this.#api.getConfig !== 'function'
|
|
1045
|
+
|| typeof this.#api.sendTyping !== 'function') return false;
|
|
1046
|
+
if (this.#typingTarget === target) return true;
|
|
1047
|
+
|
|
1048
|
+
const generation = ++this.#typingGeneration;
|
|
1049
|
+
try {
|
|
1050
|
+
return await this.#queueTyping(async () => {
|
|
1051
|
+
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
|
1052
|
+
let ticket = this.#typingTicket;
|
|
1053
|
+
if (!ticket) {
|
|
1054
|
+
const config = await this.#api.getConfig({
|
|
1055
|
+
baseUrl: this.#baseUrl,
|
|
1056
|
+
token: this.#token,
|
|
1057
|
+
toUserId: target,
|
|
1058
|
+
contextToken,
|
|
1059
|
+
signal: this.#signal,
|
|
1060
|
+
});
|
|
1061
|
+
ticket = nonEmptyString(config?.typingTicket);
|
|
1062
|
+
if (!ticket) {
|
|
1063
|
+
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
|
1067
|
+
this.#typingTicket = ticket;
|
|
1068
|
+
this.#typingRetryAt = 0;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
this.#typingTarget = target;
|
|
1072
|
+
await this.#api.sendTyping({
|
|
1073
|
+
baseUrl: this.#baseUrl,
|
|
1074
|
+
token: this.#token,
|
|
1075
|
+
toUserId: target,
|
|
1076
|
+
typingTicket: ticket,
|
|
1077
|
+
status: 1,
|
|
1078
|
+
signal: this.#signal,
|
|
1079
|
+
});
|
|
1080
|
+
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
|
1081
|
+
this.#typingTicketStale = false;
|
|
1082
|
+
this.#typingRetryAt = 0;
|
|
1083
|
+
this.#scheduleTyping(generation);
|
|
1084
|
+
return true;
|
|
1085
|
+
});
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
if (generation === this.#typingGeneration) {
|
|
1088
|
+
this.#clearTypingTimer();
|
|
1089
|
+
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
|
|
1090
|
+
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
|
1091
|
+
}
|
|
1092
|
+
if (!this.#signal?.aborted && !this.#typingClosed) {
|
|
1093
|
+
this.#logger.warn?.('[dsh-weixin] typing indicator failed:', error);
|
|
1094
|
+
}
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async #stopTyping({ signal = AbortSignal.timeout(5_000) } = {}) {
|
|
1100
|
+
++this.#typingGeneration;
|
|
1101
|
+
this.#clearTypingTimer();
|
|
1102
|
+
const target = this.#typingTarget;
|
|
1103
|
+
const ticket = this.#typingTicket;
|
|
1104
|
+
const stale = this.#typingTicketStale;
|
|
1105
|
+
this.#typingTarget = null;
|
|
1106
|
+
if (typeof this.#api.sendTyping !== 'function') return false;
|
|
1107
|
+
try {
|
|
1108
|
+
return await this.#queueTyping(async () => {
|
|
1109
|
+
if (!target || !ticket) return false;
|
|
1110
|
+
try {
|
|
1111
|
+
await this.#api.sendTyping({
|
|
1112
|
+
baseUrl: this.#baseUrl,
|
|
1113
|
+
token: this.#token,
|
|
1114
|
+
toUserId: target,
|
|
1115
|
+
typingTicket: ticket,
|
|
1116
|
+
status: 2,
|
|
1117
|
+
signal,
|
|
1118
|
+
});
|
|
1119
|
+
} finally {
|
|
1120
|
+
if (stale && this.#typingTicket === ticket) this.#typingTicket = null;
|
|
1121
|
+
this.#typingTicketStale = false;
|
|
1122
|
+
}
|
|
1123
|
+
return true;
|
|
1124
|
+
});
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (!signal?.aborted) {
|
|
1127
|
+
this.#logger.warn?.('[dsh-weixin] typing cancellation failed:', error);
|
|
1128
|
+
}
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
#scheduleTyping(generation) {
|
|
1134
|
+
this.#clearTypingTimer();
|
|
1135
|
+
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) return;
|
|
1136
|
+
const timer = setTimeout(() => {
|
|
1137
|
+
if (this.#typingTimer === timer) this.#typingTimer = null;
|
|
1138
|
+
void this.#queueTyping(async () => {
|
|
1139
|
+
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
await this.#api.sendTyping({
|
|
1143
|
+
baseUrl: this.#baseUrl,
|
|
1144
|
+
token: this.#token,
|
|
1145
|
+
toUserId: this.#typingTarget,
|
|
1146
|
+
typingTicket: this.#typingTicket,
|
|
1147
|
+
status: 1,
|
|
1148
|
+
signal: this.#signal,
|
|
1149
|
+
});
|
|
1150
|
+
return true;
|
|
1151
|
+
}).then((sent) => {
|
|
1152
|
+
if (sent) this.#scheduleTyping(generation);
|
|
1153
|
+
}).catch((error) => {
|
|
1154
|
+
if (generation !== this.#typingGeneration) return;
|
|
1155
|
+
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
|
|
1156
|
+
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
|
1157
|
+
if (!this.#signal?.aborted && !this.#typingClosed) {
|
|
1158
|
+
this.#logger.warn?.('[dsh-weixin] typing keepalive failed:', error);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}, this.#typingKeepaliveMs);
|
|
1162
|
+
timer.unref?.();
|
|
1163
|
+
this.#typingTimer = timer;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
#clearTypingTimer() {
|
|
1167
|
+
if (this.#typingTimer) clearTimeout(this.#typingTimer);
|
|
1168
|
+
this.#typingTimer = null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
#resumeTyping(key, toUserId, contextToken) {
|
|
1172
|
+
if (this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)) {
|
|
1173
|
+
return Promise.resolve(false);
|
|
1174
|
+
}
|
|
1175
|
+
return this.#startTyping(toUserId, contextToken);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
968
1178
|
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
|
969
1179
|
const sendArtifact = (method, file) => this.#api[method]({
|
|
970
1180
|
baseUrl: this.#baseUrl,
|
|
@@ -988,9 +1198,13 @@ export class WeixinHarnessBridge {
|
|
|
988
1198
|
sendFile: typeof this.#api.sendFile === 'function'
|
|
989
1199
|
? (file) => sendArtifact('sendFile', file)
|
|
990
1200
|
: undefined,
|
|
991
|
-
|
|
1201
|
+
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
|
1202
|
+
userMessage: artifactFailureText(artifact?.fileName, error),
|
|
1203
|
+
reason: error?.code,
|
|
1204
|
+
}),
|
|
1205
|
+
sendFailureNotice: (_artifact, _error, failure) => this.#send(
|
|
992
1206
|
toUserId,
|
|
993
|
-
|
|
1207
|
+
messageFailureText(failure),
|
|
994
1208
|
contextToken,
|
|
995
1209
|
runId,
|
|
996
1210
|
),
|
|
@@ -1000,6 +1214,10 @@ export class WeixinHarnessBridge {
|
|
|
1000
1214
|
+ delivery.artifactsSent;
|
|
1001
1215
|
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
|
1002
1216
|
+ delivery.artifactSendErrors;
|
|
1003
|
-
return {
|
|
1217
|
+
return {
|
|
1218
|
+
receipt: delivery.receipt,
|
|
1219
|
+
userVisible: delivery.userVisible,
|
|
1220
|
+
artifactSendErrors: delivery.artifactSendErrors,
|
|
1221
|
+
};
|
|
1004
1222
|
}
|
|
1005
1223
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
connectionTestMessage,
|
|
11
11
|
connectionTestTargetUnavailable,
|
|
12
12
|
} from '../shared/connection-test.mjs';
|
|
13
|
+
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
13
14
|
import { t } from '../shared/i18n.mjs';
|
|
14
15
|
|
|
15
16
|
const ACTIVE_ATTEMPT_STATES = new Set([
|
|
@@ -74,20 +75,6 @@ function safeAccountError(code, message) {
|
|
|
74
75
|
return Object.freeze({ code, message });
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
function publicMessageError(value) {
|
|
78
|
-
if (!value || typeof value !== 'object'
|
|
79
|
-
|| typeof value.code !== 'string' || !value.code
|
|
80
|
-
|| typeof value.reason !== 'string' || !value.reason
|
|
81
|
-
|| typeof value.message !== 'string' || !value.message
|
|
82
|
-
|| !Number.isFinite(value.at)) return null;
|
|
83
|
-
return {
|
|
84
|
-
code: value.code.slice(0, 64),
|
|
85
|
-
reason: value.reason.slice(0, 128),
|
|
86
|
-
message: value.message.slice(0, 500),
|
|
87
|
-
at: value.at,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
78
|
function activationStageError(code, cause) {
|
|
92
79
|
const error = new Error(`Weixin activation failed during ${code}`, { cause });
|
|
93
80
|
error.name = 'WeixinActivationStageError';
|
|
@@ -377,7 +364,7 @@ export class WeixinController {
|
|
|
377
364
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
378
365
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
379
366
|
},
|
|
380
|
-
lastMessageError:
|
|
367
|
+
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
|
381
368
|
error: error ? structuredClone(error) : null,
|
|
382
369
|
};
|
|
383
370
|
});
|
|
@@ -285,6 +285,7 @@ export class WeixinRuntime {
|
|
|
285
285
|
this.#abortController?.abort();
|
|
286
286
|
this.#abortController = null;
|
|
287
287
|
this.#monitor = null;
|
|
288
|
+
await bridge?.close?.();
|
|
288
289
|
await monitor?.catch(() => undefined);
|
|
289
290
|
await bridge?.waitForIdle();
|
|
290
291
|
this.#bridge = null;
|
|
@@ -6,6 +6,7 @@ export const WHATSAPP_DESCRIPTOR = Object.freeze({
|
|
|
6
6
|
label: 'WhatsApp',
|
|
7
7
|
// Translated lazily: t() must run after setImHostLanguage, not at import time.
|
|
8
8
|
get connectionLabel() { return t(' Web 关联设备'); },
|
|
9
|
+
reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
export class WhatsappHarnessBridge extends TextHarnessBridge {
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
4
4
|
import { t } from '../shared/i18n.mjs';
|
|
5
|
+
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
5
6
|
import {
|
|
6
7
|
deriveWhatsappBotId,
|
|
7
8
|
maskWhatsappAccount,
|
|
@@ -285,6 +286,7 @@ export class WhatsappController {
|
|
|
285
286
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
286
287
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
287
288
|
},
|
|
289
|
+
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
|
288
290
|
accessPolicy: normalizeWhatsappAccessPolicy(config),
|
|
289
291
|
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
290
292
|
};
|
|
@@ -246,6 +246,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|
|
246
246
|
addressed: !group || fromMe || mentioned || replyToSelf,
|
|
247
247
|
selfChat,
|
|
248
248
|
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
|
249
|
+
reactionTarget: { jid: remoteJid, key: message.key },
|
|
249
250
|
};
|
|
250
251
|
}
|
|
251
252
|
|
|
@@ -411,6 +412,41 @@ export class WhatsappBotClient {
|
|
|
411
412
|
}, 'image');
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
async addReaction(target, emoji, { signal } = {}) {
|
|
416
|
+
if (typeof emoji !== 'string' || !emoji.trim()) {
|
|
417
|
+
throw new TypeError('A WhatsApp reaction emoji is required');
|
|
418
|
+
}
|
|
419
|
+
const reactionKey = emoji.trim();
|
|
420
|
+
await this.#sendReaction(target, reactionKey, signal);
|
|
421
|
+
return reactionKey;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
removeReaction(target, _reactionKey, { signal } = {}) {
|
|
425
|
+
return this.#sendReaction(target, '', signal);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async #sendReaction(target, text, signal) {
|
|
429
|
+
if (typeof target?.jid !== 'string' || !target.jid || !target.key?.id) {
|
|
430
|
+
throw new TypeError('A WhatsApp reaction target is required');
|
|
431
|
+
}
|
|
432
|
+
const operationSignal = signal ?? this.#signal;
|
|
433
|
+
operationSignal?.throwIfAborted();
|
|
434
|
+
const messageId = randomBytes(10).toString('hex').toUpperCase();
|
|
435
|
+
if (typeof this.#outboundIds.reserve === 'function') {
|
|
436
|
+
this.#outboundIds.reserve(messageId);
|
|
437
|
+
} else {
|
|
438
|
+
this.#outboundIds.remember(messageId);
|
|
439
|
+
}
|
|
440
|
+
const pending = this.#socket.sendMessage(
|
|
441
|
+
target.jid,
|
|
442
|
+
{ react: { text, key: target.key } },
|
|
443
|
+
{ messageId },
|
|
444
|
+
);
|
|
445
|
+
const result = await waitWithSignal(pending, operationSignal);
|
|
446
|
+
this.#outboundIds.remember(result?.key?.id);
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
414
450
|
async #sendArtifact(target, file, content, presentation) {
|
|
415
451
|
this.#signal?.throwIfAborted();
|
|
416
452
|
await this.#stopTyping(target.jid);
|