@xmanrui/dsh-im 2.5.0 → 3.0.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 +4 -4
- package/README.md +4 -4
- package/bin/dsh-im.mjs +1 -1
- package/lib/client.js +1410 -1409
- package/lib/index.js +206 -206
- package/package.json +2 -2
- package/plugin-src/client/channels/dingtalk/index.js +0 -17
- package/plugin-src/client/channels/dingtalk/styles.js +2 -2
- package/plugin-src/client/channels/feishu/index.js +0 -29
- package/plugin-src/client/channels/feishu/styles.js +2 -2
- package/plugin-src/client/channels/qq/index.js +0 -9
- package/plugin-src/client/channels/wecom/index.js +0 -9
- package/plugin-src/client/channels/weixin/index.js +0 -19
- package/plugin-src/client/channels/weixin/styles.js +2 -2
- package/plugin-src/client/index.js +4 -4
- package/scripts/verify-package.mjs +48 -11
- package/src/channels/dingtalk/dingtalk-api.mjs +101 -5
- package/src/channels/dingtalk/dingtalk-bridge.mjs +198 -22
- 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 +21 -28
- package/src/channels/feishu/message-utils.mjs +1 -1
- package/src/channels/shared/connection-test.mjs +1 -1
- package/src/channels/shared/i18n-en/feishu.mjs +6 -6
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -2
- package/src/channels/shared/status-reaction.mjs +107 -0
- package/src/channels/shared/text-harness-bridge.mjs +58 -6
- 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-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/whatsapp/whatsapp-bridge.mjs +1 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +36 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DINGTALK_DONE_REACTION_NAME,
|
|
3
|
+
DINGTALK_ERROR_REACTION_NAME,
|
|
4
|
+
DINGTALK_THINKING_REACTION_NAME,
|
|
2
5
|
normalizeDingtalkSessionWebhook,
|
|
3
6
|
splitDingtalkText,
|
|
4
7
|
} from './dingtalk-api.mjs';
|
|
@@ -309,7 +312,15 @@ function canClaimInteractionReply(message, pending, sender) {
|
|
|
309
312
|
|
|
310
313
|
function ensureStats(status) {
|
|
311
314
|
status.stats ??= {};
|
|
312
|
-
for (const key of [
|
|
315
|
+
for (const key of [
|
|
316
|
+
'messagesReceived',
|
|
317
|
+
'messagesReplied',
|
|
318
|
+
'messagesRejected',
|
|
319
|
+
'messagesIgnored',
|
|
320
|
+
'reactionsAdded',
|
|
321
|
+
'reactionsRemoved',
|
|
322
|
+
'reactionErrors',
|
|
323
|
+
]) {
|
|
313
324
|
status[key] ??= 0;
|
|
314
325
|
status.stats[key] = status[key];
|
|
315
326
|
}
|
|
@@ -328,6 +339,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
|
|
|
328
339
|
messagesReplied: 0,
|
|
329
340
|
messagesRejected: 0,
|
|
330
341
|
messagesIgnored: 0,
|
|
342
|
+
reactionsAdded: 0,
|
|
343
|
+
reactionsRemoved: 0,
|
|
344
|
+
reactionErrors: 0,
|
|
331
345
|
lastMessageAt: null,
|
|
332
346
|
lastReplyAt: null,
|
|
333
347
|
lastRejectedAt: null,
|
|
@@ -339,6 +353,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
|
|
|
339
353
|
messagesReplied: 0,
|
|
340
354
|
messagesRejected: 0,
|
|
341
355
|
messagesIgnored: 0,
|
|
356
|
+
reactionsAdded: 0,
|
|
357
|
+
reactionsRemoved: 0,
|
|
358
|
+
reactionErrors: 0,
|
|
342
359
|
},
|
|
343
360
|
};
|
|
344
361
|
}
|
|
@@ -352,6 +369,7 @@ export class DingtalkHarnessBridge {
|
|
|
352
369
|
#status;
|
|
353
370
|
#logger;
|
|
354
371
|
#replyTimeoutMs;
|
|
372
|
+
#reactionTimeoutMs;
|
|
355
373
|
#maxMessageChars;
|
|
356
374
|
#signal;
|
|
357
375
|
#queues = new Map();
|
|
@@ -372,6 +390,7 @@ export class DingtalkHarnessBridge {
|
|
|
372
390
|
status = createDingtalkBridgeStatus(),
|
|
373
391
|
logger = console,
|
|
374
392
|
replyTimeoutMs = 600_000,
|
|
393
|
+
reactionTimeoutMs = 5_000,
|
|
375
394
|
maxMessageChars = 4_000,
|
|
376
395
|
signal,
|
|
377
396
|
}) {
|
|
@@ -389,6 +408,9 @@ export class DingtalkHarnessBridge {
|
|
|
389
408
|
this.#logger = logger;
|
|
390
409
|
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
|
|
391
410
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
411
|
+
this.#reactionTimeoutMs = Number.isFinite(reactionTimeoutMs) && reactionTimeoutMs > 0
|
|
412
|
+
? Math.floor(reactionTimeoutMs)
|
|
413
|
+
: 5_000;
|
|
392
414
|
this.#maxMessageChars = maxMessageChars;
|
|
393
415
|
this.#signal = signal;
|
|
394
416
|
ensureStats(this.#status);
|
|
@@ -436,15 +458,35 @@ export class DingtalkHarnessBridge {
|
|
|
436
458
|
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
|
437
459
|
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
|
438
460
|
const direct = String(message.conversationType) !== '2';
|
|
461
|
+
const statusReaction = sessionWebhook && addressed ? this.#startStatusReaction(message) : null;
|
|
462
|
+
const finish = (task) => Promise.resolve(task).then(
|
|
463
|
+
(value) => {
|
|
464
|
+
this.#finishStatusReaction(
|
|
465
|
+
statusReaction,
|
|
466
|
+
this.#signal?.aborted ? 'clear' : 'success',
|
|
467
|
+
);
|
|
468
|
+
return value;
|
|
469
|
+
},
|
|
470
|
+
(error) => {
|
|
471
|
+
this.#finishStatusReaction(
|
|
472
|
+
statusReaction,
|
|
473
|
+
this.#signal?.aborted || error?.name === 'AbortError' || error?.code === 'turn-stopped'
|
|
474
|
+
? 'clear'
|
|
475
|
+
: 'error',
|
|
476
|
+
);
|
|
477
|
+
throw error;
|
|
478
|
+
},
|
|
479
|
+
);
|
|
439
480
|
const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
|
|
440
481
|
&& isBatchInputCommand(commandText);
|
|
441
482
|
const batchStatus = this.#batchInputs.status(key);
|
|
442
483
|
if (batchCommand && !direct && sessionWebhook && addressed) {
|
|
443
|
-
return this.#finishBatchResult(
|
|
484
|
+
return finish(this.#finishBatchResult(
|
|
444
485
|
messageId,
|
|
445
486
|
sessionWebhook,
|
|
446
487
|
{ message: batchInputGroupUnsupportedMessage() },
|
|
447
|
-
|
|
488
|
+
statusReaction,
|
|
489
|
+
));
|
|
448
490
|
}
|
|
449
491
|
if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
|
|
450
492
|
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
|
@@ -460,7 +502,7 @@ export class DingtalkHarnessBridge {
|
|
|
460
502
|
});
|
|
461
503
|
if (result.handled) {
|
|
462
504
|
if (result.kind === 'submit') {
|
|
463
|
-
return this.#enqueueMessage(
|
|
505
|
+
return finish(this.#enqueueMessage(
|
|
464
506
|
{
|
|
465
507
|
...message,
|
|
466
508
|
msgtype: 'text',
|
|
@@ -469,10 +511,15 @@ export class DingtalkHarnessBridge {
|
|
|
469
511
|
messageId,
|
|
470
512
|
sender,
|
|
471
513
|
key,
|
|
472
|
-
{ batchSubmission: result },
|
|
473
|
-
);
|
|
514
|
+
{ batchSubmission: result, statusReaction },
|
|
515
|
+
));
|
|
474
516
|
}
|
|
475
|
-
return this.#finishBatchResult(
|
|
517
|
+
return finish(this.#finishBatchResult(
|
|
518
|
+
messageId,
|
|
519
|
+
sessionWebhook,
|
|
520
|
+
result,
|
|
521
|
+
statusReaction,
|
|
522
|
+
));
|
|
476
523
|
}
|
|
477
524
|
}
|
|
478
525
|
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
|
@@ -490,7 +537,11 @@ export class DingtalkHarnessBridge {
|
|
|
490
537
|
promptMessage,
|
|
491
538
|
commandRunner,
|
|
492
539
|
).catch((error) => {
|
|
493
|
-
if (error?.code === 'turn-stopped' || this.#signal?.aborted)
|
|
540
|
+
if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
|
|
541
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
494
545
|
this.#status.lastError = error?.message ?? String(error);
|
|
495
546
|
const failure = setLastMessageFailure(this.#status, error);
|
|
496
547
|
this.#logger.error?.(
|
|
@@ -503,7 +554,7 @@ export class DingtalkHarnessBridge {
|
|
|
503
554
|
this.#commandTasks.delete(task);
|
|
504
555
|
});
|
|
505
556
|
this.#commandTasks.add(task);
|
|
506
|
-
return task;
|
|
557
|
+
return finish(task);
|
|
507
558
|
}
|
|
508
559
|
const approvalReply = this.#approvals.claimReply({
|
|
509
560
|
key,
|
|
@@ -537,7 +588,11 @@ export class DingtalkHarnessBridge {
|
|
|
537
588
|
return true;
|
|
538
589
|
})
|
|
539
590
|
.catch((error) => {
|
|
540
|
-
if (this.#signal?.aborted)
|
|
591
|
+
if (this.#signal?.aborted) {
|
|
592
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
541
596
|
this.#status.lastError = t('钉钉审批处理失败。');
|
|
542
597
|
this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
|
|
543
598
|
})
|
|
@@ -546,18 +601,18 @@ export class DingtalkHarnessBridge {
|
|
|
546
601
|
this.#interactionTasks.delete(current);
|
|
547
602
|
});
|
|
548
603
|
this.#interactionTasks.add(current);
|
|
549
|
-
return current;
|
|
604
|
+
return finish(current);
|
|
550
605
|
}
|
|
551
606
|
|
|
552
607
|
if (pending && pending.actor !== sender) {
|
|
553
|
-
return this.#enqueueMessage(message, messageId, sender, key);
|
|
608
|
+
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
|
554
609
|
}
|
|
555
610
|
// Once one valid answer has been claimed, later messages are subsequent
|
|
556
611
|
// prompts even if the network submission eventually needs a retry. Invalid
|
|
557
612
|
// replies do not claim the question, so the next valid answer can still
|
|
558
613
|
// pass through this interaction queue.
|
|
559
614
|
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
560
|
-
return this.#enqueueMessage(message, messageId, sender, key);
|
|
615
|
+
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
|
561
616
|
}
|
|
562
617
|
if (pending) {
|
|
563
618
|
if (canClaimInteractionReply(message, pending, sender)) {
|
|
@@ -566,7 +621,14 @@ export class DingtalkHarnessBridge {
|
|
|
566
621
|
const previous = pending.queue ?? Promise.resolve();
|
|
567
622
|
const current = previous
|
|
568
623
|
.catch(() => undefined)
|
|
569
|
-
.then(() => this.#processInteractionReply(
|
|
624
|
+
.then(() => this.#processInteractionReply(
|
|
625
|
+
message,
|
|
626
|
+
messageId,
|
|
627
|
+
sender,
|
|
628
|
+
key,
|
|
629
|
+
pending,
|
|
630
|
+
statusReaction,
|
|
631
|
+
))
|
|
570
632
|
.finally(() => {
|
|
571
633
|
this.#acceptedMessageIds.delete(messageId);
|
|
572
634
|
if (pending.claimedReplyMessageId === messageId) {
|
|
@@ -575,15 +637,101 @@ export class DingtalkHarnessBridge {
|
|
|
575
637
|
if (pending.queue === current) pending.queue = null;
|
|
576
638
|
});
|
|
577
639
|
pending.queue = current;
|
|
578
|
-
return current;
|
|
640
|
+
return finish(current);
|
|
579
641
|
}
|
|
580
|
-
return this.#enqueueMessage(message, messageId, sender, key);
|
|
642
|
+
return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#runReactionCall(method, target, reactionName, kind) {
|
|
646
|
+
const controller = new AbortController();
|
|
647
|
+
const operation = Promise.resolve().then(() => this.#api[method]({
|
|
648
|
+
clientId: this.#clientId,
|
|
649
|
+
clientSecret: this.#clientSecret,
|
|
650
|
+
...target,
|
|
651
|
+
reactionName,
|
|
652
|
+
signal: controller.signal,
|
|
653
|
+
}));
|
|
654
|
+
let timer;
|
|
655
|
+
const timeout = new Promise((_, reject) => {
|
|
656
|
+
timer = setTimeout(() => {
|
|
657
|
+
const error = new DOMException('DingTalk reaction timed out', 'TimeoutError');
|
|
658
|
+
controller.abort(error);
|
|
659
|
+
reject(error);
|
|
660
|
+
}, this.#reactionTimeoutMs);
|
|
661
|
+
timer.unref?.();
|
|
662
|
+
});
|
|
663
|
+
return Promise.race([operation, timeout])
|
|
664
|
+
.then(() => {
|
|
665
|
+
increment(this.#status, kind === 'add' ? 'reactionsAdded' : 'reactionsRemoved');
|
|
666
|
+
return true;
|
|
667
|
+
})
|
|
668
|
+
.catch((error) => {
|
|
669
|
+
increment(this.#status, 'reactionErrors');
|
|
670
|
+
this.#logger.debug?.(`[dsh-dingtalk] ${method} failed`, safeErrorDiagnostic(error));
|
|
671
|
+
return false;
|
|
672
|
+
})
|
|
673
|
+
.finally(() => clearTimeout(timer));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#startStatusReaction(message) {
|
|
677
|
+
if (typeof this.#api.addReaction !== 'function'
|
|
678
|
+
|| typeof this.#api.recallReaction !== 'function') return null;
|
|
679
|
+
const messageId = nonEmptyString(message?.msgId);
|
|
680
|
+
const conversationId = nonEmptyString(message?.conversationId);
|
|
681
|
+
if (!messageId || !conversationId) return null;
|
|
682
|
+
const target = {
|
|
683
|
+
messageId,
|
|
684
|
+
conversationId,
|
|
685
|
+
robotCode: nonEmptyString(message?.robotCode) ?? this.#clientId,
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
target,
|
|
689
|
+
attached: this.#runReactionCall(
|
|
690
|
+
'addReaction',
|
|
691
|
+
target,
|
|
692
|
+
DINGTALK_THINKING_REACTION_NAME,
|
|
693
|
+
'add',
|
|
694
|
+
),
|
|
695
|
+
terminal: false,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
#finishStatusReaction(reaction, outcome) {
|
|
700
|
+
if (!reaction || reaction.terminal) return;
|
|
701
|
+
reaction.terminal = true;
|
|
702
|
+
const terminalName = outcome === 'success'
|
|
703
|
+
? DINGTALK_DONE_REACTION_NAME
|
|
704
|
+
: outcome === 'error' ? DINGTALK_ERROR_REACTION_NAME : null;
|
|
705
|
+
// Preserve attach -> recall -> terminal ordering without extending the message task.
|
|
706
|
+
void reaction.attached.then(async (attached) => {
|
|
707
|
+
let cleaned = await this.#runReactionCall(
|
|
708
|
+
'recallReaction',
|
|
709
|
+
reaction.target,
|
|
710
|
+
DINGTALK_THINKING_REACTION_NAME,
|
|
711
|
+
'remove',
|
|
712
|
+
);
|
|
713
|
+
if (!attached || !cleaned) {
|
|
714
|
+
await new Promise((resolve) => {
|
|
715
|
+
const retry = setTimeout(resolve, Math.min(1_000, this.#reactionTimeoutMs));
|
|
716
|
+
retry.unref?.();
|
|
717
|
+
});
|
|
718
|
+
cleaned = await this.#runReactionCall(
|
|
719
|
+
'recallReaction',
|
|
720
|
+
reaction.target,
|
|
721
|
+
DINGTALK_THINKING_REACTION_NAME,
|
|
722
|
+
'remove',
|
|
723
|
+
) || cleaned;
|
|
724
|
+
}
|
|
725
|
+
if (!cleaned || !terminalName || this.#signal?.aborted) return;
|
|
726
|
+
await this.#runReactionCall('addReaction', reaction.target, terminalName, 'add');
|
|
727
|
+
}).catch(() => undefined);
|
|
581
728
|
}
|
|
582
729
|
|
|
583
730
|
#enqueueMessage(message, messageId, sender, key, {
|
|
584
731
|
releaseMessageId = true,
|
|
585
732
|
alreadyRecorded = false,
|
|
586
733
|
batchSubmission = null,
|
|
734
|
+
statusReaction = null,
|
|
587
735
|
} = {}) {
|
|
588
736
|
let hasSafeReplyRoute = false;
|
|
589
737
|
try {
|
|
@@ -607,6 +755,7 @@ export class DingtalkHarnessBridge {
|
|
|
607
755
|
alreadyRecorded,
|
|
608
756
|
preparedMessage,
|
|
609
757
|
batchSubmission,
|
|
758
|
+
statusReaction,
|
|
610
759
|
}))
|
|
611
760
|
.finally(() => {
|
|
612
761
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
@@ -659,7 +808,7 @@ export class DingtalkHarnessBridge {
|
|
|
659
808
|
this.#status.lastError = null;
|
|
660
809
|
}
|
|
661
810
|
|
|
662
|
-
#finishBatchResult(messageId, sessionWebhook, result) {
|
|
811
|
+
#finishBatchResult(messageId, sessionWebhook, result, statusReaction) {
|
|
663
812
|
let task;
|
|
664
813
|
task = Promise.resolve().then(async () => {
|
|
665
814
|
if (this.#state.hasSeen(messageId)) return;
|
|
@@ -669,7 +818,11 @@ export class DingtalkHarnessBridge {
|
|
|
669
818
|
if (result.message) await this.#send(sessionWebhook, result.message);
|
|
670
819
|
this.#status.lastError = null;
|
|
671
820
|
}).catch(async (error) => {
|
|
672
|
-
if (this.#signal?.aborted)
|
|
821
|
+
if (this.#signal?.aborted) {
|
|
822
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
673
826
|
this.#status.lastError = error?.message ?? String(error);
|
|
674
827
|
const failure = setLastMessageFailure(this.#status, error);
|
|
675
828
|
this.#logger.error?.(
|
|
@@ -689,6 +842,7 @@ export class DingtalkHarnessBridge {
|
|
|
689
842
|
alreadyRecorded = false,
|
|
690
843
|
preparedMessage,
|
|
691
844
|
batchSubmission = null,
|
|
845
|
+
statusReaction = null,
|
|
692
846
|
} = {}) {
|
|
693
847
|
this.#signal?.throwIfAborted();
|
|
694
848
|
if (!alreadyRecorded) {
|
|
@@ -865,10 +1019,15 @@ export class DingtalkHarnessBridge {
|
|
|
865
1019
|
batchSettled = true;
|
|
866
1020
|
}
|
|
867
1021
|
if (error?.code === 'turn-stopped') {
|
|
1022
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
868
1023
|
if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
|
|
869
1024
|
return;
|
|
870
1025
|
}
|
|
871
|
-
if (this.#signal?.aborted)
|
|
1026
|
+
if (this.#signal?.aborted) {
|
|
1027
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
872
1031
|
this.#status.lastError = error?.message ?? String(error);
|
|
873
1032
|
const userMessage = inboundFileUserMessage(error)
|
|
874
1033
|
?? dingtalkImageErrorUserMessage(error);
|
|
@@ -896,7 +1055,14 @@ export class DingtalkHarnessBridge {
|
|
|
896
1055
|
}
|
|
897
1056
|
}
|
|
898
1057
|
|
|
899
|
-
async #processInteractionReply(
|
|
1058
|
+
async #processInteractionReply(
|
|
1059
|
+
message,
|
|
1060
|
+
messageId,
|
|
1061
|
+
sender,
|
|
1062
|
+
key,
|
|
1063
|
+
expected,
|
|
1064
|
+
statusReaction,
|
|
1065
|
+
) {
|
|
900
1066
|
this.#signal?.throwIfAborted();
|
|
901
1067
|
const current = this.#pendingInteractions.get(key);
|
|
902
1068
|
const claimed = expected.claimedReplyMessageId === messageId;
|
|
@@ -904,7 +1070,10 @@ export class DingtalkHarnessBridge {
|
|
|
904
1070
|
if (claimed && (!current || current !== expected)) {
|
|
905
1071
|
return this.#discardResolvedInteractionReply(message, messageId);
|
|
906
1072
|
}
|
|
907
|
-
return this.#enqueueMessage(message, messageId, sender, key, {
|
|
1073
|
+
return this.#enqueueMessage(message, messageId, sender, key, {
|
|
1074
|
+
releaseMessageId: false,
|
|
1075
|
+
statusReaction,
|
|
1076
|
+
});
|
|
908
1077
|
}
|
|
909
1078
|
if (this.#state.hasSeen(messageId)) return;
|
|
910
1079
|
await this.#state.markSeen(messageId);
|
|
@@ -949,6 +1118,7 @@ export class DingtalkHarnessBridge {
|
|
|
949
1118
|
return this.#enqueueMessage(message, messageId, sender, key, {
|
|
950
1119
|
releaseMessageId: false,
|
|
951
1120
|
alreadyRecorded: true,
|
|
1121
|
+
statusReaction,
|
|
952
1122
|
});
|
|
953
1123
|
}
|
|
954
1124
|
pending.sessionWebhook = sessionWebhook;
|
|
@@ -956,6 +1126,7 @@ export class DingtalkHarnessBridge {
|
|
|
956
1126
|
try {
|
|
957
1127
|
await this.#presentInteraction(pending);
|
|
958
1128
|
} catch {
|
|
1129
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
959
1130
|
this.#status.lastError = t('钉钉交互问题发送失败。');
|
|
960
1131
|
this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
|
|
961
1132
|
pending.interaction.reconnect?.();
|
|
@@ -975,6 +1146,7 @@ export class DingtalkHarnessBridge {
|
|
|
975
1146
|
try {
|
|
976
1147
|
await this.#presentInteraction(pending);
|
|
977
1148
|
} catch {
|
|
1149
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
978
1150
|
this.#status.lastError = t('钉钉交互问题发送失败。');
|
|
979
1151
|
this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
|
|
980
1152
|
pending.interaction.reconnect?.();
|
|
@@ -994,7 +1166,10 @@ export class DingtalkHarnessBridge {
|
|
|
994
1166
|
this.#clearPendingInteraction(key, pending.interactionId);
|
|
995
1167
|
this.#status.lastError = null;
|
|
996
1168
|
} catch (error) {
|
|
997
|
-
if (this.#signal?.aborted)
|
|
1169
|
+
if (this.#signal?.aborted) {
|
|
1170
|
+
this.#finishStatusReaction(statusReaction, 'clear');
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
998
1173
|
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
999
1174
|
if (error?.code === 'interaction-not-pending') {
|
|
1000
1175
|
this.#clearPendingInteraction(key, pending.interactionId);
|
|
@@ -1005,6 +1180,7 @@ export class DingtalkHarnessBridge {
|
|
|
1005
1180
|
}
|
|
1006
1181
|
return;
|
|
1007
1182
|
}
|
|
1183
|
+
this.#finishStatusReaction(statusReaction, 'error');
|
|
1008
1184
|
pending.submitting = false;
|
|
1009
1185
|
pending.answers.pop();
|
|
1010
1186
|
pending.index -= 1;
|
|
@@ -88,6 +88,12 @@ function snowflake(value, name) {
|
|
|
88
88
|
return id;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function reactionEmoji(value) {
|
|
92
|
+
const emoji = cleanString(value);
|
|
93
|
+
if (!emoji) throw new TypeError('A Discord reaction emoji is required');
|
|
94
|
+
return emoji;
|
|
95
|
+
}
|
|
96
|
+
|
|
91
97
|
export function validDiscordToken(value) {
|
|
92
98
|
return typeof value === 'string'
|
|
93
99
|
&& /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
|
@@ -229,6 +235,35 @@ export class DiscordApi {
|
|
|
229
235
|
});
|
|
230
236
|
}
|
|
231
237
|
|
|
238
|
+
async addOwnReaction({ channelId, messageId, emoji, signal } = {}) {
|
|
239
|
+
const normalizedEmoji = reactionEmoji(emoji);
|
|
240
|
+
await this.#request(
|
|
241
|
+
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
|
|
242
|
+
+ `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
|
|
243
|
+
{
|
|
244
|
+
method: 'PUT',
|
|
245
|
+
signal,
|
|
246
|
+
expectBody: false,
|
|
247
|
+
retry: false,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
return normalizedEmoji;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
removeOwnReaction({ channelId, messageId, emoji, signal } = {}) {
|
|
254
|
+
const normalizedEmoji = reactionEmoji(emoji);
|
|
255
|
+
return this.#request(
|
|
256
|
+
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
|
|
257
|
+
+ `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
|
|
258
|
+
{
|
|
259
|
+
method: 'DELETE',
|
|
260
|
+
signal,
|
|
261
|
+
expectBody: false,
|
|
262
|
+
retry: false,
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
232
267
|
async #request(path, {
|
|
233
268
|
method,
|
|
234
269
|
body,
|
|
@@ -4,6 +4,7 @@ export const DISCORD_DESCRIPTOR = Object.freeze({
|
|
|
4
4
|
key: 'discord',
|
|
5
5
|
label: 'Discord',
|
|
6
6
|
connectionLabel: ' Gateway 长连接',
|
|
7
|
+
reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
|
|
7
8
|
});
|
|
8
9
|
|
|
9
10
|
export class DiscordHarnessBridge extends TextHarnessBridge {
|
|
@@ -231,6 +231,10 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
231
231
|
channelId: String(message.channel_id),
|
|
232
232
|
replyToMessageId: String(message.id),
|
|
233
233
|
},
|
|
234
|
+
reactionTarget: {
|
|
235
|
+
channelId: String(message.channel_id),
|
|
236
|
+
messageId: String(message.id),
|
|
237
|
+
},
|
|
234
238
|
connectionTestTarget: { channelId: String(message.channel_id) },
|
|
235
239
|
};
|
|
236
240
|
}
|
|
@@ -350,6 +354,24 @@ export class DiscordBotClient {
|
|
|
350
354
|
});
|
|
351
355
|
}
|
|
352
356
|
|
|
357
|
+
addReaction(target, emoji, { signal } = {}) {
|
|
358
|
+
return this.#api.addOwnReaction({
|
|
359
|
+
channelId: target.channelId,
|
|
360
|
+
messageId: target.messageId,
|
|
361
|
+
emoji,
|
|
362
|
+
signal: this.#operationSignal(signal),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
removeReaction(target, reactionKey, { signal } = {}) {
|
|
367
|
+
return this.#api.removeOwnReaction({
|
|
368
|
+
channelId: target.channelId,
|
|
369
|
+
messageId: target.messageId,
|
|
370
|
+
emoji: reactionKey,
|
|
371
|
+
signal: this.#operationSignal(signal),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
353
375
|
async openStream(target) {
|
|
354
376
|
const notice = !this.#deliveredNotices.has(target) && target?.notice
|
|
355
377
|
? String(target.notice) : null;
|
|
@@ -381,6 +403,10 @@ export class DiscordBotClient {
|
|
|
381
403
|
});
|
|
382
404
|
return stream.start();
|
|
383
405
|
}
|
|
406
|
+
|
|
407
|
+
#operationSignal(signal) {
|
|
408
|
+
return signal ?? this.#signal;
|
|
409
|
+
}
|
|
384
410
|
}
|
|
385
411
|
|
|
386
412
|
export function createDiscordRuntimeStatus() {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
messageFailureText,
|
|
56
56
|
setLastMessageFailure,
|
|
57
57
|
} from '../shared/message-failure.mjs';
|
|
58
|
+
import { beginStatusReaction } from '../shared/status-reaction.mjs';
|
|
58
59
|
import {
|
|
59
60
|
MENU_PAGE_SIZE,
|
|
60
61
|
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
|
@@ -158,7 +159,7 @@ function artifactFailureText(fileName, error) {
|
|
|
158
159
|
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
|
|
159
160
|
switch (error?.code) {
|
|
160
161
|
case 'artifact-permission-required':
|
|
161
|
-
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair
|
|
162
|
+
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
|
|
162
163
|
case 'artifact-too-large':
|
|
163
164
|
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
|
|
164
165
|
case 'artifact-empty':
|
|
@@ -549,7 +550,7 @@ export class FeishuHarnessBridge {
|
|
|
549
550
|
}
|
|
550
551
|
|
|
551
552
|
this.#acceptedMessageIds.add(messageId);
|
|
552
|
-
const processingReaction = this.#
|
|
553
|
+
const processingReaction = this.#beginReaction(messageId);
|
|
553
554
|
const commandMessage = extractInboundMessage(event, this.#client);
|
|
554
555
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
555
556
|
const batchText = event.message.message_type === 'text'
|
|
@@ -944,7 +945,10 @@ export class FeishuHarnessBridge {
|
|
|
944
945
|
const text = message.content;
|
|
945
946
|
const hasImages = hasInboundImages(message);
|
|
946
947
|
const hasFiles = hasInboundFiles(message);
|
|
947
|
-
|
|
948
|
+
// 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
|
|
949
|
+
// 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
|
|
950
|
+
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
|
951
|
+
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
|
948
952
|
if (!text && !hasImages && !hasFiles) {
|
|
949
953
|
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
|
|
950
954
|
return;
|
|
@@ -3603,35 +3607,24 @@ export class FeishuHarnessBridge {
|
|
|
3603
3607
|
return t('_{text}_', { text: update.text || t('正在处理…') });
|
|
3604
3608
|
}
|
|
3605
3609
|
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
return null;
|
|
3616
|
-
}
|
|
3610
|
+
#beginReaction(messageId) {
|
|
3611
|
+
return beginStatusReaction({
|
|
3612
|
+
adapter: this.#channel,
|
|
3613
|
+
target: messageId,
|
|
3614
|
+
reactions: { processing: 'OnIt', success: 'DONE', error: 'ERROR' },
|
|
3615
|
+
status: this.#status,
|
|
3616
|
+
logger: this.#logger,
|
|
3617
|
+
label: 'feishu',
|
|
3618
|
+
});
|
|
3617
3619
|
}
|
|
3618
3620
|
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
if (reactionId && this.#channel?.removeReaction) {
|
|
3622
|
-
try {
|
|
3623
|
-
await this.#channel.removeReaction(messageId, reactionId);
|
|
3624
|
-
this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
|
|
3625
|
-
} catch (error) {
|
|
3626
|
-
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
|
3627
|
-
this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
|
|
3628
|
-
}
|
|
3629
|
-
}
|
|
3621
|
+
#removeProcessingReaction(_messageId, processingReaction) {
|
|
3622
|
+
processingReaction.clear();
|
|
3630
3623
|
}
|
|
3631
3624
|
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3625
|
+
#finishReaction(_messageId, processingReaction, finalEmojiType) {
|
|
3626
|
+
if (finalEmojiType === 'ERROR') processingReaction.error();
|
|
3627
|
+
else processingReaction.success();
|
|
3635
3628
|
}
|
|
3636
3629
|
|
|
3637
3630
|
async #send(chatId, text) {
|
|
@@ -5,7 +5,7 @@ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
|
5
5
|
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
6
6
|
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
7
7
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
8
|
-
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair
|
|
8
|
+
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
9
9
|
|
|
10
10
|
export function conversationKey(event) {
|
|
11
11
|
const chatType = event?.message?.chat_type;
|
|
@@ -29,7 +29,7 @@ export function connectionTestTarget(state) {
|
|
|
29
29
|
|
|
30
30
|
export function connectionTestMessage(botName, channelLabel = t('机器人')) {
|
|
31
31
|
const name = cleanText(botName) ?? channelLabel;
|
|
32
|
-
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('
|
|
32
|
+
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。', { name })}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function connectionTestTargetUnavailable(channelLabel = t('机器人')) {
|
|
@@ -18,8 +18,8 @@ export default {
|
|
|
18
18
|
'机器人正在移除或已重新接入,无法操作原会话的工作区。':
|
|
19
19
|
'The bot is being removed or has been reconnected; the original session’s workspace cannot be changed.',
|
|
20
20
|
'操作失败,请稍后重试。': 'The operation failed. Please try again later.',
|
|
21
|
-
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair
|
|
22
|
-
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the
|
|
21
|
+
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
|
|
22
|
+
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
|
|
23
23
|
'结果文件「{name}」超过飞书 30 MB 上限,未发送。':
|
|
24
24
|
'The result file "{name}" exceeds the Feishu 30 MB limit and was not sent.',
|
|
25
25
|
'结果文件「{name}」为空,飞书不允许发送空文件。':
|
|
@@ -28,8 +28,8 @@ export default {
|
|
|
28
28
|
'The result file "{name}" was temporarily rate-limited by Feishu and could not be sent. Please try again later.',
|
|
29
29
|
'结果文件「{name}」已生成,但暂时未能发送,请稍后重试。':
|
|
30
30
|
'The result file "{name}" was generated but could not be sent right now. Please try again later.',
|
|
31
|
-
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness
|
|
32
|
-
'Message processing failed. Please try again later. If the problem persists, check the connection status on the
|
|
31
|
+
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的「IM机器人」设置页检查飞书连接状态。':
|
|
32
|
+
'Message processing failed. Please try again later. If the problem persists, check the Feishu connection status on the IM Bot settings page in DeepSeek Harness.',
|
|
33
33
|
'已开启全新 Harness 会话。': 'A brand-new Harness session has started.',
|
|
34
34
|
'飞书机器人与 DeepSeek Harness 连接正常。':
|
|
35
35
|
'The Feishu bot is connected to DeepSeek Harness and working normally.',
|
|
@@ -338,8 +338,8 @@ export default {
|
|
|
338
338
|
'飞书机器人': 'Feishu bot',
|
|
339
339
|
|
|
340
340
|
// feishu/message-utils.mjs
|
|
341
|
-
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair
|
|
342
|
-
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the
|
|
341
|
+
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
|
|
342
|
+
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
|
|
343
343
|
|
|
344
344
|
// feishu/feishu-runtime.mjs — callback probe notices
|
|
345
345
|
'✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。':
|