@xmanrui/dsh-im 4.22.0 → 4.23.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 +3 -3
- package/README.md +3 -3
- package/lib/client.js +2505 -1152
- package/lib/index.js +290 -299
- package/package.json +8 -1
- package/plugin-src/client/channel-card-meta.js +2 -0
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/api.js +5 -1
- package/plugin-src/client/channels/dingtalk/index.js +15 -13
- package/plugin-src/client/channels/email/api.js +52 -0
- package/plugin-src/client/channels/email/index.js +728 -0
- package/plugin-src/client/channels/email/styles.js +51 -0
- package/plugin-src/client/channels/feishu/api.js +4 -2
- package/plugin-src/client/channels/feishu/index.js +16 -3
- package/plugin-src/client/channels/imessage/index.js +2 -1
- package/plugin-src/client/channels/office/api.js +2 -0
- package/plugin-src/client/channels/office/index.js +6 -5
- package/plugin-src/client/channels/qq/api.js +7 -0
- package/plugin-src/client/channels/qq/index.js +16 -3
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +28 -3
- package/plugin-src/client/channels/slack/index.js +2 -1
- package/plugin-src/client/channels/wecom/api.js +7 -1
- package/plugin-src/client/channels/wecom/index.js +16 -3
- package/plugin-src/client/channels/wecom-app/api.js +6 -1
- package/plugin-src/client/channels/wecom-app/index.js +16 -3
- package/plugin-src/client/channels/weixin/api.js +2 -1
- package/plugin-src/client/channels/weixin/connection-error.js +1 -71
- package/plugin-src/client/channels/whatsapp/api.js +5 -0
- package/plugin-src/client/channels/whatsapp/index.js +16 -3
- package/plugin-src/client/connection-error.js +87 -0
- package/plugin-src/client/global-settings.js +81 -1
- package/plugin-src/client/i18n.js +61 -0
- package/plugin-src/client/index.js +30 -2
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/styles.js +6 -0
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
- package/plugin-src/host/channels/email/availability.mjs +60 -0
- package/plugin-src/host/channels/email/index.mjs +36 -0
- package/plugin-src/host/channels/email/production.mjs +58 -0
- package/plugin-src/host/channels/email/rpc.mjs +163 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
- package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
- package/plugin-src/host/channels/office/rpc.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -0
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +52 -5
- package/plugin-src/host/channels/shared/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
- package/plugin-src/host/channels/shared/startup.mjs +11 -6
- package/plugin-src/host/channels/slack/production.mjs +2 -0
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +2 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
- package/plugin-src/host/delivery-adapter.mjs +11 -0
- package/plugin-src/host/image-input-rpc.mjs +24 -0
- package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/connection-error.mjs +5 -12
- package/src/channels/dingtalk/device-auth.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
- package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
- package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
- package/src/channels/discord/discord-api.mjs +3 -3
- package/src/channels/discord/discord-runtime.mjs +18 -12
- package/src/channels/email/config-store.mjs +194 -0
- package/src/channels/email/email-api.mjs +20 -0
- package/src/channels/email/email-bridge.mjs +17 -0
- package/src/channels/email/email-controller.mjs +824 -0
- package/src/channels/email/email-runtime.mjs +692 -0
- package/src/channels/email/harness-client.mjs +7 -0
- package/src/channels/email/mail-format.mjs +91 -0
- package/src/channels/email/state-store.mjs +176 -0
- package/src/channels/email/transport.mjs +64 -0
- package/src/channels/email/transports/agent-mail.mjs +593 -0
- package/src/channels/email/transports/agently-cli.mjs +288 -0
- package/src/channels/email/transports/imap-smtp.mjs +165 -0
- package/src/channels/feishu/bridge.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/message-utils.mjs +3 -2
- package/src/channels/feishu/multi-bot-controller.mjs +56 -47
- package/src/channels/feishu/registration-manager.mjs +8 -4
- package/src/channels/feishu/repair-manager.mjs +2 -1
- package/src/channels/imessage/runtime.mjs +9 -5
- package/src/channels/office/office-controller.mjs +23 -12
- package/src/channels/office/office-runtime.mjs +6 -3
- package/src/channels/office/office-transport.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +7 -5
- package/src/channels/qq/qq-controller.mjs +56 -42
- package/src/channels/qq/qq-runtime.mjs +16 -11
- package/src/channels/shared/bot-workspace-store.mjs +29 -10
- package/src/channels/shared/connection-error.mjs +191 -0
- package/src/channels/shared/connection-test.mjs +3 -1
- package/src/channels/shared/conversation-state-store.mjs +46 -1
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +63 -18
- package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
- package/src/channels/shared/i18n-en/email.mjs +25 -0
- package/src/channels/shared/i18n-en/image-input.mjs +17 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
- package/src/channels/shared/i18n-en.mjs +6 -0
- package/src/channels/shared/image-input-policy.mjs +35 -0
- package/src/channels/shared/image-input-settings-store.mjs +60 -0
- package/src/channels/shared/image-input.mjs +124 -0
- package/src/channels/shared/image-prompt.mjs +15 -7
- package/src/channels/shared/message-failure.mjs +12 -0
- package/src/channels/shared/semantic/reply-reference.mjs +3 -3
- package/src/channels/shared/text-harness-bridge.mjs +90 -11
- package/src/channels/shared/token-bot-controller.mjs +52 -47
- package/src/channels/slack/slack-api.mjs +3 -3
- package/src/channels/slack/slack-controller.mjs +59 -51
- package/src/channels/slack/slack-runtime.mjs +18 -12
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-runtime.mjs +17 -13
- package/src/channels/wecom/wecom-bridge.mjs +15 -6
- package/src/channels/wecom/wecom-controller.mjs +51 -37
- package/src/channels/wecom/wecom-runtime.mjs +10 -6
- package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
- package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
- package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
- package/src/channels/weixin/connection-error.mjs +26 -92
- package/src/channels/weixin/diagnostic-details.mjs +1 -63
- package/src/channels/weixin/weixin-bridge.mjs +7 -5
- package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
- package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
providerMessageIdsFor,
|
|
65
65
|
} from './semantic/delivery.mjs';
|
|
66
66
|
import {
|
|
67
|
+
messageFailureDiagnostic,
|
|
67
68
|
channelDeliveryFailure,
|
|
68
69
|
clearLastMessageFailure,
|
|
69
70
|
messageFailureText,
|
|
@@ -74,6 +75,15 @@ import { beginStatusReaction } from './status-reaction.mjs';
|
|
|
74
75
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
75
76
|
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* How many times one inbound message may be re-delivered after a failed turn.
|
|
80
|
+
*
|
|
81
|
+
* A failure releases the message's `markSeen` so the next poll retries it, which
|
|
82
|
+
* would otherwise loop forever on a mail that always fails. After this many
|
|
83
|
+
* attempts the mark stays and the message is treated as handled.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
86
|
+
|
|
77
87
|
function cleanText(value) {
|
|
78
88
|
return typeof value === 'string' ? value.trim() : '';
|
|
79
89
|
}
|
|
@@ -83,7 +93,7 @@ function canClaimInteractionReply(message, pending, senderId) {
|
|
|
83
93
|
&& (message.kind !== 'group' || message.addressed === true)
|
|
84
94
|
&& !hasInboundImages(message)
|
|
85
95
|
&& !hasInboundFiles(message)
|
|
86
|
-
&& Boolean(
|
|
96
|
+
&& Boolean(controlTextOf(message));
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
function artifactFailureText(fileName, error, descriptor) {
|
|
@@ -138,6 +148,18 @@ export function createTextBridgeStatus() {
|
|
|
138
148
|
};
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The text a channel's control commands and approval decisions are parsed from.
|
|
153
|
+
*
|
|
154
|
+
* A channel may decorate `content` for the model — the email channel prepends
|
|
155
|
+
* the mail headers so a subject line reaches the model — while `controlText`
|
|
156
|
+
* keeps the undecorated body. Without this a decorated message made `/help`
|
|
157
|
+
* and "批准" unrecognisable.
|
|
158
|
+
*/
|
|
159
|
+
function controlTextOf(message) {
|
|
160
|
+
return cleanText(message?.controlText ?? message?.content);
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
export class TextHarnessBridge {
|
|
142
164
|
#descriptor;
|
|
143
165
|
#bot;
|
|
@@ -161,6 +183,10 @@ export class TextHarnessBridge {
|
|
|
161
183
|
#approvals;
|
|
162
184
|
#batches = new BatchInputManager();
|
|
163
185
|
#interactionCard;
|
|
186
|
+
// Delivery attempts per message id. A failed turn releases its `markSeen` so
|
|
187
|
+
// the next poll can retry, and this counter is what keeps that from becoming
|
|
188
|
+
// an infinite loop on a mail that always fails.
|
|
189
|
+
#deliveryAttempts = new Map();
|
|
164
190
|
|
|
165
191
|
constructor({
|
|
166
192
|
descriptor,
|
|
@@ -229,7 +255,11 @@ export class TextHarnessBridge {
|
|
|
229
255
|
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
|
|
230
256
|
conversationType: kind,
|
|
231
257
|
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
|
|
232
|
-
text
|
|
258
|
+
// The same control text the command runner below parses. A channel may
|
|
259
|
+
// decorate `content` for the model (email prepends the mail headers), and
|
|
260
|
+
// a decorated body never looks like a command — so reading `content` here
|
|
261
|
+
// recognized no command at all and skipped the command-permission gate.
|
|
262
|
+
text: controlTextOf(normalized),
|
|
233
263
|
hasImages,
|
|
234
264
|
hasFiles,
|
|
235
265
|
});
|
|
@@ -282,7 +312,7 @@ export class TextHarnessBridge {
|
|
|
282
312
|
|
|
283
313
|
const key = `${normalized.kind}:${normalized.conversationId}`;
|
|
284
314
|
const pending = this.#pendingInteractions.get(key);
|
|
285
|
-
const text =
|
|
315
|
+
const text = controlTextOf(normalized);
|
|
286
316
|
const batchCommand = isBatchInputCommand(text);
|
|
287
317
|
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
|
|
288
318
|
return this.#finishLocalMessage(
|
|
@@ -362,7 +392,12 @@ export class TextHarnessBridge {
|
|
|
362
392
|
key,
|
|
363
393
|
actor: senderId,
|
|
364
394
|
messageId,
|
|
365
|
-
|
|
395
|
+
// Approval decisions are parsed from the control text, exactly like the
|
|
396
|
+
// commands above: a decorated `content` hid "批准" behind the mail headers,
|
|
397
|
+
// so a plain-body approval never claimed its pending request.
|
|
398
|
+
text: hasInboundImages(normalized) || hasInboundFiles(normalized)
|
|
399
|
+
? ''
|
|
400
|
+
: controlTextOf(normalized),
|
|
366
401
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
367
402
|
hasPendingQuestion: Boolean(pending),
|
|
368
403
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -441,7 +476,7 @@ export class TextHarnessBridge {
|
|
|
441
476
|
const failure = setLastMessageFailure(this.#status, error);
|
|
442
477
|
this.#logger.error?.(
|
|
443
478
|
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
|
|
444
|
-
error,
|
|
479
|
+
messageFailureDiagnostic(error, failure),
|
|
445
480
|
);
|
|
446
481
|
}).finally(() => {
|
|
447
482
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -500,7 +535,7 @@ export class TextHarnessBridge {
|
|
|
500
535
|
const target = message.replyTarget;
|
|
501
536
|
try {
|
|
502
537
|
const result = await runner(
|
|
503
|
-
|
|
538
|
+
controlTextOf(message),
|
|
504
539
|
this.#harness,
|
|
505
540
|
this.#state,
|
|
506
541
|
key,
|
|
@@ -550,7 +585,7 @@ export class TextHarnessBridge {
|
|
|
550
585
|
const failure = setLastMessageFailure(this.#status, error);
|
|
551
586
|
this.#logger.error?.(
|
|
552
587
|
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
|
|
553
|
-
error,
|
|
588
|
+
messageFailureDiagnostic(error, failure),
|
|
554
589
|
);
|
|
555
590
|
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
|
|
556
591
|
}
|
|
@@ -615,7 +650,7 @@ export class TextHarnessBridge {
|
|
|
615
650
|
}
|
|
616
651
|
|
|
617
652
|
const target = message.replyTarget;
|
|
618
|
-
const text =
|
|
653
|
+
const text = controlTextOf(message);
|
|
619
654
|
const batchSubmission = message.batchSubmission;
|
|
620
655
|
let stream = null;
|
|
621
656
|
let semanticStream = false;
|
|
@@ -710,7 +745,7 @@ export class TextHarnessBridge {
|
|
|
710
745
|
}
|
|
711
746
|
}
|
|
712
747
|
let content = hasImages || hasReply
|
|
713
|
-
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
748
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
714
749
|
: undefined;
|
|
715
750
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
716
751
|
let contextEnhanced = false;
|
|
@@ -790,6 +825,7 @@ export class TextHarnessBridge {
|
|
|
790
825
|
},
|
|
791
826
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
792
827
|
files: message.files,
|
|
828
|
+
images: message.images,
|
|
793
829
|
},
|
|
794
830
|
});
|
|
795
831
|
stopKeepalive();
|
|
@@ -877,6 +913,8 @@ export class TextHarnessBridge {
|
|
|
877
913
|
clearLastMessageFailure(this.#status);
|
|
878
914
|
}
|
|
879
915
|
}
|
|
916
|
+
// The turn produced an answer, so its retry budget is spent and reset.
|
|
917
|
+
this.#deliveryAttempts.delete(messageId);
|
|
880
918
|
return delivery.receipt;
|
|
881
919
|
} catch (error) {
|
|
882
920
|
stopKeepalive();
|
|
@@ -934,7 +972,7 @@ export class TextHarnessBridge {
|
|
|
934
972
|
: messageFailureText(failure);
|
|
935
973
|
this.#logger.error?.(
|
|
936
974
|
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
|
|
937
|
-
error,
|
|
975
|
+
messageFailureDiagnostic(error, failure),
|
|
938
976
|
);
|
|
939
977
|
if (await presentStreamFailure(failureText)) {
|
|
940
978
|
return error.deliveryReceipt;
|
|
@@ -948,6 +986,10 @@ export class TextHarnessBridge {
|
|
|
948
986
|
sendError,
|
|
949
987
|
);
|
|
950
988
|
}
|
|
989
|
+
// The turn produced no answer, so this id is "attempted", not "handled".
|
|
990
|
+
// Releasing the mark lets the next poll retry it; `alreadyRecorded` means
|
|
991
|
+
// an outer caller owns the mark, so it is left alone there.
|
|
992
|
+
if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
|
|
951
993
|
return error.deliveryReceipt;
|
|
952
994
|
} finally {
|
|
953
995
|
stopKeepalive();
|
|
@@ -958,6 +1000,43 @@ export class TextHarnessBridge {
|
|
|
958
1000
|
}
|
|
959
1001
|
}
|
|
960
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Release the `markSeen` of a turn that failed without delivering anything,
|
|
1005
|
+
* so the next poll can pick the message up again.
|
|
1006
|
+
*
|
|
1007
|
+
* Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
|
|
1008
|
+
* which the mark stays and the id becomes a tombstone. That keeps a mail that
|
|
1009
|
+
* always fails from being re-executed forever, while a transient Harness
|
|
1010
|
+
* outage no longer drops it permanently.
|
|
1011
|
+
*
|
|
1012
|
+
* Returns true when the message was released and will be retried.
|
|
1013
|
+
*/
|
|
1014
|
+
async #releaseFailedDelivery(messageId) {
|
|
1015
|
+
const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
|
|
1016
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1017
|
+
// Give up: keep the mark so the id is never replayed, and stop tracking
|
|
1018
|
+
// it. The sender has already been told the turn failed.
|
|
1019
|
+
this.#deliveryAttempts.delete(messageId);
|
|
1020
|
+
this.#logger.warn?.(
|
|
1021
|
+
`[dsh-im:${this.#descriptor.key}] giving up on a message after `
|
|
1022
|
+
+ `${attempts} failed delivery attempts; it will not be retried.`,
|
|
1023
|
+
);
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
this.#deliveryAttempts.set(messageId, attempts);
|
|
1027
|
+
if (typeof this.#state?.unmarkSeen !== 'function') return false;
|
|
1028
|
+
try {
|
|
1029
|
+
await this.#state.unmarkSeen(messageId);
|
|
1030
|
+
return true;
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
this.#logger.warn?.(
|
|
1033
|
+
`[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
|
|
1034
|
+
error,
|
|
1035
|
+
);
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
961
1040
|
/**
|
|
962
1041
|
* Advance the pending interaction with one answer.
|
|
963
1042
|
*
|
|
@@ -1000,7 +1079,7 @@ export class TextHarnessBridge {
|
|
|
1000
1079
|
}
|
|
1001
1080
|
|
|
1002
1081
|
const target = message.replyTarget;
|
|
1003
|
-
const text =
|
|
1082
|
+
const text = controlTextOf(message);
|
|
1004
1083
|
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
|
|
1005
1084
|
try {
|
|
1006
1085
|
await this.#bot.sendText(target, t('请用文字回答当前问题。'));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from './connection-error.mjs';
|
|
1
2
|
import { connectionTestMessage } from './connection-test.mjs';
|
|
2
3
|
import { t } from './i18n.mjs';
|
|
3
4
|
import { publicMessageFailure } from './message-failure.mjs';
|
|
@@ -20,6 +21,7 @@ export class TokenBotController {
|
|
|
20
21
|
#createRuntime;
|
|
21
22
|
#deleteState;
|
|
22
23
|
#logger;
|
|
24
|
+
#diagnostics;
|
|
23
25
|
#runtimes = new Map();
|
|
24
26
|
#errors = new Map();
|
|
25
27
|
#transitions = new Map();
|
|
@@ -61,6 +63,14 @@ export class TokenBotController {
|
|
|
61
63
|
this.#createRuntime = createRuntime;
|
|
62
64
|
this.#deleteState = deleteState;
|
|
63
65
|
this.#logger = logger;
|
|
66
|
+
this.#diagnostics = createConnectionDiagnostics({ channel: descriptor.key, logger });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get diagnostics() { return this.#diagnostics; }
|
|
70
|
+
|
|
71
|
+
#failure(error, code, message) {
|
|
72
|
+
const stage = code.startsWith('qr-') ? 'qr.begin' : code === 'activation-failed' ? 'activation' : 'connection.start';
|
|
73
|
+
return this.#diagnostics.report(error, { reuse: true, stage, publicError: { code, message } }).publicError;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
async initialize() {
|
|
@@ -68,30 +78,27 @@ export class TokenBotController {
|
|
|
68
78
|
for (const config of this.#configStore.list()) {
|
|
69
79
|
await this.#withBotTransition(config.botId, async () => {
|
|
70
80
|
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
71
|
-
const token = await this.#resolveToken(config.tokenRef);
|
|
72
|
-
if (!token) {
|
|
73
|
-
this.#errors.set(config.botId, safeError(
|
|
74
|
-
'missing-token',
|
|
75
|
-
t('{label}机器人凭据缺失,请移除后重新接入。', {
|
|
76
|
-
label: this.#descriptor.label,
|
|
77
|
-
}),
|
|
78
|
-
));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
81
|
try {
|
|
82
|
+
const token = await this.#resolveToken(config.tokenRef);
|
|
83
|
+
if (!token) {
|
|
84
|
+
this.#errors.set(config.botId, safeError(
|
|
85
|
+
'missing-token',
|
|
86
|
+
t('{label}机器人凭据缺失,请移除后重新接入。', {
|
|
87
|
+
label: this.#descriptor.label,
|
|
88
|
+
}),
|
|
89
|
+
));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
82
92
|
await this.#startRuntime(config, token);
|
|
83
93
|
this.#errors.delete(config.botId);
|
|
84
94
|
} catch (error) {
|
|
85
|
-
this.#errors.set(config.botId,
|
|
95
|
+
this.#errors.set(config.botId, this.#failure(error,
|
|
86
96
|
'connection-failed',
|
|
87
97
|
t('{label}连接未就绪,插件会自动重试。', {
|
|
88
98
|
label: this.#descriptor.label,
|
|
89
99
|
}),
|
|
90
100
|
));
|
|
91
|
-
|
|
92
|
-
`[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`,
|
|
93
|
-
error,
|
|
94
|
-
);
|
|
101
|
+
|
|
95
102
|
} finally {
|
|
96
103
|
this.#touch();
|
|
97
104
|
}
|
|
@@ -112,7 +119,7 @@ export class TokenBotController {
|
|
|
112
119
|
await this.#withBotTransition(identity.botId, async () => {
|
|
113
120
|
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
114
121
|
const previousConfig = this.#configStore.getByPlatformId(platformId);
|
|
115
|
-
const previousToken = await this.#credentials.resolve(identity.tokenRef)
|
|
122
|
+
const previousToken = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.tokenRef), 'credential-store');
|
|
116
123
|
const config = {
|
|
117
124
|
botId: identity.botId,
|
|
118
125
|
platformId,
|
|
@@ -122,9 +129,9 @@ export class TokenBotController {
|
|
|
122
129
|
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
123
130
|
connectedAt: new Date().toISOString(),
|
|
124
131
|
};
|
|
125
|
-
await this.#credentials.set(identity.tokenRef, normalizedToken);
|
|
132
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(identity.tokenRef, normalizedToken), 'credential-store');
|
|
126
133
|
try {
|
|
127
|
-
await this.#configStore.save(config);
|
|
134
|
+
await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
|
|
128
135
|
} catch (error) {
|
|
129
136
|
await this.#restoreCredential(identity.tokenRef, previousToken);
|
|
130
137
|
throw error;
|
|
@@ -133,16 +140,13 @@ export class TokenBotController {
|
|
|
133
140
|
await this.#startRuntime(config, normalizedToken);
|
|
134
141
|
this.#errors.delete(identity.botId);
|
|
135
142
|
} catch (error) {
|
|
136
|
-
this.#errors.set(identity.botId,
|
|
143
|
+
this.#errors.set(identity.botId, this.#failure(error,
|
|
137
144
|
'connection-failed',
|
|
138
145
|
t('{label}机器人已接入,消息连接暂未就绪。', {
|
|
139
146
|
label: this.#descriptor.label,
|
|
140
147
|
}),
|
|
141
148
|
));
|
|
142
|
-
|
|
143
|
-
`[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`,
|
|
144
|
-
error,
|
|
145
|
-
);
|
|
149
|
+
|
|
146
150
|
}
|
|
147
151
|
this.#touch();
|
|
148
152
|
});
|
|
@@ -159,7 +163,7 @@ export class TokenBotController {
|
|
|
159
163
|
await this.#startRuntime(config, token);
|
|
160
164
|
this.#errors.delete(botId);
|
|
161
165
|
} catch (error) {
|
|
162
|
-
this.#errors.set(botId,
|
|
166
|
+
this.#errors.set(botId, this.#failure(error,
|
|
163
167
|
'connection-failed',
|
|
164
168
|
t('{label}连接仍未就绪,请稍后重试。', {
|
|
165
169
|
label: this.#descriptor.label,
|
|
@@ -184,12 +188,12 @@ export class TokenBotController {
|
|
|
184
188
|
if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
|
|
185
189
|
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
186
190
|
const nextConfig = update(config);
|
|
187
|
-
const savedConfig = await this.#configStore.save(nextConfig);
|
|
191
|
+
const savedConfig = await atConnectionStage('account.save', () => this.#configStore.save(nextConfig), 'account-config');
|
|
188
192
|
try {
|
|
189
193
|
await this.#startRuntime(savedConfig, token);
|
|
190
194
|
this.#errors.delete(botId);
|
|
191
195
|
} catch (error) {
|
|
192
|
-
this.#errors.set(botId,
|
|
196
|
+
this.#errors.set(botId, this.#failure(error,
|
|
193
197
|
'connection-failed',
|
|
194
198
|
t('{label}连接仍未就绪,请稍后重试。', {
|
|
195
199
|
label: this.#descriptor.label,
|
|
@@ -244,31 +248,32 @@ export class TokenBotController {
|
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
async deleteBot(botId) {
|
|
251
|
+
const warnings = [];
|
|
247
252
|
const config = this.#configStore.get(botId);
|
|
248
253
|
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
|
249
254
|
await this.#withBotTransition(botId, async () => {
|
|
250
|
-
const previous = await this.#credentials.resolve(config.tokenRef)
|
|
255
|
+
const previous = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.tokenRef), 'credential-store');
|
|
251
256
|
await this.#stopRuntime(botId);
|
|
252
257
|
try {
|
|
253
|
-
await this.#credentials.unset(config.tokenRef);
|
|
254
|
-
await this.#configStore.remove(botId);
|
|
258
|
+
await atConnectionStage('credential.remove', () => this.#credentials.unset(config.tokenRef), 'credential-store');
|
|
259
|
+
await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config');
|
|
255
260
|
} catch (error) {
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
if (!this.#configStore.get(botId)) {
|
|
262
|
+
warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
|
|
263
|
+
publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
|
|
264
|
+
} else {
|
|
265
|
+
if (previous?.value) {
|
|
266
|
+
await atConnectionStage('credential.save', () => this.#credentials.set(config.tokenRef, previous.value), 'credential-store').catch(() => undefined);
|
|
267
|
+
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
|
|
259
270
|
}
|
|
260
|
-
throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
|
|
261
271
|
}
|
|
262
|
-
await this.#deleteState({ botId, config }).catch(
|
|
263
|
-
this.#logger.warn?.(
|
|
264
|
-
`[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`,
|
|
265
|
-
error,
|
|
266
|
-
);
|
|
267
|
-
});
|
|
272
|
+
await this.#deleteState({ botId, config }).catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', resource: 'account-state', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError); });
|
|
268
273
|
this.#errors.delete(botId);
|
|
269
274
|
this.#touch();
|
|
270
275
|
});
|
|
271
|
-
return this.status();
|
|
276
|
+
return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
status() {
|
|
@@ -309,7 +314,7 @@ export class TokenBotController {
|
|
|
309
314
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
310
315
|
},
|
|
311
316
|
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
|
312
|
-
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
317
|
+
error: structuredClone(runtimeStatus?.error ?? this.#errors.get(config.botId) ?? null),
|
|
313
318
|
};
|
|
314
319
|
});
|
|
315
320
|
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
@@ -342,7 +347,7 @@ export class TokenBotController {
|
|
|
342
347
|
} catch (error) {
|
|
343
348
|
this.#logger.warn?.(
|
|
344
349
|
`[dsh-im:${this.#descriptor.key}] bot ${botId} command menu refresh failed:`,
|
|
345
|
-
error,
|
|
350
|
+
extractConnectionEvidence(error).details,
|
|
346
351
|
);
|
|
347
352
|
return false;
|
|
348
353
|
}
|
|
@@ -361,7 +366,7 @@ export class TokenBotController {
|
|
|
361
366
|
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
362
367
|
await this.#stopRuntime(config.botId);
|
|
363
368
|
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
|
364
|
-
const runtime = await this.#createRuntime({ botId: config.botId, config, token });
|
|
369
|
+
const runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({ botId: config.botId, config, token }));
|
|
365
370
|
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
366
371
|
throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`);
|
|
367
372
|
}
|
|
@@ -381,19 +386,19 @@ export class TokenBotController {
|
|
|
381
386
|
await runtime?.stop().catch((error) => {
|
|
382
387
|
this.#logger.warn?.(
|
|
383
388
|
`[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`,
|
|
384
|
-
error,
|
|
389
|
+
extractConnectionEvidence(error).details,
|
|
385
390
|
);
|
|
386
391
|
});
|
|
387
392
|
}
|
|
388
393
|
|
|
389
394
|
async #resolveToken(ref) {
|
|
390
|
-
const result = await this.#credentials.resolve(ref)
|
|
395
|
+
const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(ref), 'credential-store');
|
|
391
396
|
return cleanString(result?.value);
|
|
392
397
|
}
|
|
393
398
|
|
|
394
399
|
async #restoreCredential(ref, previous) {
|
|
395
|
-
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
|
396
|
-
else await this.#credentials.unset(ref).catch(() => undefined);
|
|
400
|
+
if (previous?.value) await atConnectionStage('credential.save', () => this.#credentials.set(ref, previous.value), 'credential-store').catch(() => undefined);
|
|
401
|
+
else await atConnectionStage('credential.remove', () => this.#credentials.unset(ref), 'credential-store').catch(() => undefined);
|
|
397
402
|
}
|
|
398
403
|
|
|
399
404
|
#withBotTransition(botId, operation) {
|
|
@@ -522,7 +522,7 @@ export class SlackApi {
|
|
|
522
522
|
});
|
|
523
523
|
} catch (error) {
|
|
524
524
|
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
525
|
-
throw new Error(`Slack ${method} transport failed
|
|
525
|
+
throw new Error(`Slack ${method} transport failed`, { cause: error });
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
if (tokenKind === 'bot') {
|
|
@@ -533,8 +533,8 @@ export class SlackApi {
|
|
|
533
533
|
let payload;
|
|
534
534
|
try {
|
|
535
535
|
payload = await response.json();
|
|
536
|
-
} catch {
|
|
537
|
-
throw new Error(`Slack ${method} returned invalid JSON
|
|
536
|
+
} catch (cause) {
|
|
537
|
+
throw Object.assign(new Error(`Slack ${method} returned invalid JSON`, { cause }), { status: response?.status });
|
|
538
538
|
}
|
|
539
539
|
if (response.status === 429 && retry) {
|
|
540
540
|
const seconds = Number(response.headers.get('retry-after')) || 1;
|