@xmanrui/dsh-im 2.5.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 +1 -1
- package/README.md +1 -1
- package/lib/client.js +2 -1
- package/lib/index.js +206 -206
- package/package.json +1 -1
- 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 +16 -26
- 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
package/package.json
CHANGED
|
@@ -8,8 +8,14 @@ export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
|
|
|
8
8
|
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
|
|
9
9
|
export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
|
|
10
10
|
export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
|
|
11
|
+
export const DINGTALK_THINKING_REACTION_NAME = '🤔思考中';
|
|
12
|
+
export const DINGTALK_DONE_REACTION_NAME = '✅已完成';
|
|
13
|
+
export const DINGTALK_ERROR_REACTION_NAME = '❌处理失败';
|
|
11
14
|
|
|
12
15
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
16
|
+
const REACTION_TIMEOUT_MS = 5_000;
|
|
17
|
+
const TEXT_REACTION_ID = '2659900';
|
|
18
|
+
const TEXT_REACTION_BACKGROUND_ID = 'im_bg_1';
|
|
13
19
|
const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
|
|
14
20
|
|
|
15
21
|
export class DingtalkApiError extends Error {
|
|
@@ -406,18 +412,20 @@ export function createDingtalkApi({
|
|
|
406
412
|
if (!source) throw new TypeError('registrationSource is required');
|
|
407
413
|
const tokenCache = new Map();
|
|
408
414
|
const tokenRequests = new Map();
|
|
415
|
+
const reactionTokenRequests = new Map();
|
|
409
416
|
let cardSlotTail = Promise.resolve();
|
|
410
417
|
let nextCardRequestAt = 0;
|
|
411
418
|
|
|
412
419
|
const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
|
|
413
420
|
|
|
414
|
-
async function accessToken({ clientId, clientSecret, signal }) {
|
|
421
|
+
async function accessToken({ clientId, clientSecret, signal, requestKind = 'normal' }) {
|
|
415
422
|
const appKey = nonEmptyString(clientId);
|
|
416
423
|
const appSecret = nonEmptyString(clientSecret);
|
|
417
424
|
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
|
418
425
|
const cached = tokenCache.get(appKey);
|
|
419
426
|
if (cached && cached.expiresAt > now()) return cached.token;
|
|
420
|
-
|
|
427
|
+
const requests = requestKind === 'reaction' ? reactionTokenRequests : tokenRequests;
|
|
428
|
+
if (requests.has(appKey)) return requests.get(appKey);
|
|
421
429
|
|
|
422
430
|
const request = (async () => {
|
|
423
431
|
const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
|
|
@@ -431,9 +439,75 @@ export function createDingtalkApi({
|
|
|
431
439
|
const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
|
|
432
440
|
tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
|
|
433
441
|
return token;
|
|
434
|
-
})()
|
|
435
|
-
|
|
436
|
-
|
|
442
|
+
})();
|
|
443
|
+
const shared = request.finally(() => requests.delete(appKey));
|
|
444
|
+
requests.set(appKey, shared);
|
|
445
|
+
return shared;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function changeReaction({
|
|
449
|
+
clientId,
|
|
450
|
+
clientSecret,
|
|
451
|
+
robotCode,
|
|
452
|
+
messageId,
|
|
453
|
+
conversationId,
|
|
454
|
+
reactionName = DINGTALK_THINKING_REACTION_NAME,
|
|
455
|
+
signal,
|
|
456
|
+
}, action) {
|
|
457
|
+
const appKey = nonEmptyString(clientId);
|
|
458
|
+
const appSecret = nonEmptyString(clientSecret);
|
|
459
|
+
const botCode = nonEmptyString(robotCode) ?? appKey;
|
|
460
|
+
const openMsgId = nonEmptyString(messageId);
|
|
461
|
+
const openConversationId = nonEmptyString(conversationId);
|
|
462
|
+
const emotionName = nonEmptyString(reactionName);
|
|
463
|
+
if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
|
|
464
|
+
if (!botCode) throw new TypeError('robotCode is required');
|
|
465
|
+
if (!openMsgId || !openConversationId) {
|
|
466
|
+
throw new TypeError('messageId and conversationId are required');
|
|
467
|
+
}
|
|
468
|
+
if (!emotionName) throw new TypeError('reactionName is required');
|
|
469
|
+
// Reactions deduplicate with each other but never own the normal reply's
|
|
470
|
+
// in-flight token request. Both paths may still reuse a cached token.
|
|
471
|
+
const token = await accessToken({
|
|
472
|
+
clientId: appKey,
|
|
473
|
+
clientSecret: appSecret,
|
|
474
|
+
signal,
|
|
475
|
+
requestKind: 'reaction',
|
|
476
|
+
});
|
|
477
|
+
const response = await requestJson(
|
|
478
|
+
fetchImpl,
|
|
479
|
+
endpoint(apiBase, `v1.0/robot/emotion/${action}`),
|
|
480
|
+
{
|
|
481
|
+
body: {
|
|
482
|
+
robotCode: botCode,
|
|
483
|
+
openMsgId,
|
|
484
|
+
openConversationId,
|
|
485
|
+
emotionType: 2,
|
|
486
|
+
emotionName,
|
|
487
|
+
textEmotion: {
|
|
488
|
+
emotionId: TEXT_REACTION_ID,
|
|
489
|
+
emotionName,
|
|
490
|
+
text: emotionName,
|
|
491
|
+
backgroundId: TEXT_REACTION_BACKGROUND_ID,
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
495
|
+
signal,
|
|
496
|
+
timeoutMs: REACTION_TIMEOUT_MS,
|
|
497
|
+
action: action === 'reply' ? '消息状态添加' : '消息状态撤回',
|
|
498
|
+
},
|
|
499
|
+
);
|
|
500
|
+
const rejection = response?.success === false
|
|
501
|
+
? safeProviderCode(response?.code ?? response?.errcode) ?? 'rejected'
|
|
502
|
+
: rejectedProviderResponse(response);
|
|
503
|
+
if (rejection) {
|
|
504
|
+
throw new DingtalkApiError(
|
|
505
|
+
'reaction-rejected',
|
|
506
|
+
'钉钉服务拒绝了消息状态请求。',
|
|
507
|
+
{ providerCode: rejection },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
437
511
|
}
|
|
438
512
|
|
|
439
513
|
async function messageFileDownloadUrl({
|
|
@@ -690,6 +764,28 @@ export function createDingtalkApi({
|
|
|
690
764
|
|
|
691
765
|
accessToken,
|
|
692
766
|
|
|
767
|
+
async addReaction(request) {
|
|
768
|
+
return changeReaction(request, 'reply');
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
async recallReaction(request) {
|
|
772
|
+
return changeReaction(request, 'recall');
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
async addThinkingReaction(request) {
|
|
776
|
+
return changeReaction({
|
|
777
|
+
...request,
|
|
778
|
+
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
|
779
|
+
}, 'reply');
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
async recallThinkingReaction(request) {
|
|
783
|
+
return changeReaction({
|
|
784
|
+
...request,
|
|
785
|
+
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
|
786
|
+
}, 'recall');
|
|
787
|
+
},
|
|
788
|
+
|
|
693
789
|
async downloadImage({
|
|
694
790
|
clientId,
|
|
695
791
|
clientSecret,
|
|
@@ -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() {
|