@xmanrui/dsh-im 4.22.0 → 4.24.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 +5 -4
- package/README.md +5 -4
- package/lib/client.js +2584 -1153
- package/lib/index.js +290 -298
- package/package.json +14 -2
- 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 +23 -7
- 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 +8 -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/telegram/index.js +3 -0
- package/plugin-src/client/channels/telegram/styles.js +12 -0
- package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
- 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 +69 -1
- package/plugin-src/client/index.js +40 -4
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/styles.js +8 -2
- 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 +14 -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/shared/thinking-traces-rpc.mjs +11 -0
- 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/host/modern-harness-api.mjs +7 -2
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +13 -5
- 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 +699 -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 +604 -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 +39 -11
- package/src/channels/feishu/feishu-channel.mjs +35 -0
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/live-cot.mjs +260 -0
- 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/feishu/slash-command-registry.mjs +17 -0
- package/src/channels/feishu/step-push-mode.mjs +10 -4
- 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 +287 -57
- 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 +150 -25
- 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/config-store.mjs +4 -1
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-controller.mjs +13 -1
- package/src/channels/telegram/telegram-runtime.mjs +215 -14
- 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;
|
|
@@ -146,6 +168,7 @@ export class TextHarnessBridge {
|
|
|
146
168
|
#deferred;
|
|
147
169
|
#contextEnhancement;
|
|
148
170
|
#accessPolicy;
|
|
171
|
+
#thinkingTraces;
|
|
149
172
|
#status;
|
|
150
173
|
#logger;
|
|
151
174
|
#replyTimeoutMs;
|
|
@@ -161,6 +184,10 @@ export class TextHarnessBridge {
|
|
|
161
184
|
#approvals;
|
|
162
185
|
#batches = new BatchInputManager();
|
|
163
186
|
#interactionCard;
|
|
187
|
+
// Delivery attempts per message id. A failed turn releases its `markSeen` so
|
|
188
|
+
// the next poll can retry, and this counter is what keeps that from becoming
|
|
189
|
+
// an infinite loop on a mail that always fails.
|
|
190
|
+
#deliveryAttempts = new Map();
|
|
164
191
|
|
|
165
192
|
constructor({
|
|
166
193
|
descriptor,
|
|
@@ -169,6 +196,7 @@ export class TextHarnessBridge {
|
|
|
169
196
|
state,
|
|
170
197
|
contextEnhancement,
|
|
171
198
|
accessPolicy,
|
|
199
|
+
thinkingTraces = false,
|
|
172
200
|
status = createTextBridgeStatus(),
|
|
173
201
|
logger = console,
|
|
174
202
|
replyTimeoutMs = 600_000,
|
|
@@ -185,6 +213,7 @@ export class TextHarnessBridge {
|
|
|
185
213
|
this.#state = state;
|
|
186
214
|
this.#contextEnhancement = contextEnhancement;
|
|
187
215
|
this.#accessPolicy = accessPolicy;
|
|
216
|
+
this.#thinkingTraces = thinkingTraces;
|
|
188
217
|
this.#status = status;
|
|
189
218
|
this.#logger = logger;
|
|
190
219
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
@@ -229,7 +258,11 @@ export class TextHarnessBridge {
|
|
|
229
258
|
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
|
|
230
259
|
conversationType: kind,
|
|
231
260
|
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
|
|
232
|
-
text
|
|
261
|
+
// The same control text the command runner below parses. A channel may
|
|
262
|
+
// decorate `content` for the model (email prepends the mail headers), and
|
|
263
|
+
// a decorated body never looks like a command — so reading `content` here
|
|
264
|
+
// recognized no command at all and skipped the command-permission gate.
|
|
265
|
+
text: controlTextOf(normalized),
|
|
233
266
|
hasImages,
|
|
234
267
|
hasFiles,
|
|
235
268
|
});
|
|
@@ -282,7 +315,7 @@ export class TextHarnessBridge {
|
|
|
282
315
|
|
|
283
316
|
const key = `${normalized.kind}:${normalized.conversationId}`;
|
|
284
317
|
const pending = this.#pendingInteractions.get(key);
|
|
285
|
-
const text =
|
|
318
|
+
const text = controlTextOf(normalized);
|
|
286
319
|
const batchCommand = isBatchInputCommand(text);
|
|
287
320
|
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
|
|
288
321
|
return this.#finishLocalMessage(
|
|
@@ -362,7 +395,12 @@ export class TextHarnessBridge {
|
|
|
362
395
|
key,
|
|
363
396
|
actor: senderId,
|
|
364
397
|
messageId,
|
|
365
|
-
|
|
398
|
+
// Approval decisions are parsed from the control text, exactly like the
|
|
399
|
+
// commands above: a decorated `content` hid "批准" behind the mail headers,
|
|
400
|
+
// so a plain-body approval never claimed its pending request.
|
|
401
|
+
text: hasInboundImages(normalized) || hasInboundFiles(normalized)
|
|
402
|
+
? ''
|
|
403
|
+
: controlTextOf(normalized),
|
|
366
404
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
367
405
|
hasPendingQuestion: Boolean(pending),
|
|
368
406
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -441,7 +479,7 @@ export class TextHarnessBridge {
|
|
|
441
479
|
const failure = setLastMessageFailure(this.#status, error);
|
|
442
480
|
this.#logger.error?.(
|
|
443
481
|
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
|
|
444
|
-
error,
|
|
482
|
+
messageFailureDiagnostic(error, failure),
|
|
445
483
|
);
|
|
446
484
|
}).finally(() => {
|
|
447
485
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -500,7 +538,7 @@ export class TextHarnessBridge {
|
|
|
500
538
|
const target = message.replyTarget;
|
|
501
539
|
try {
|
|
502
540
|
const result = await runner(
|
|
503
|
-
|
|
541
|
+
controlTextOf(message),
|
|
504
542
|
this.#harness,
|
|
505
543
|
this.#state,
|
|
506
544
|
key,
|
|
@@ -550,7 +588,7 @@ export class TextHarnessBridge {
|
|
|
550
588
|
const failure = setLastMessageFailure(this.#status, error);
|
|
551
589
|
this.#logger.error?.(
|
|
552
590
|
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
|
|
553
|
-
error,
|
|
591
|
+
messageFailureDiagnostic(error, failure),
|
|
554
592
|
);
|
|
555
593
|
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
|
|
556
594
|
}
|
|
@@ -615,10 +653,14 @@ export class TextHarnessBridge {
|
|
|
615
653
|
}
|
|
616
654
|
|
|
617
655
|
const target = message.replyTarget;
|
|
618
|
-
const text =
|
|
656
|
+
const text = controlTextOf(message);
|
|
619
657
|
const batchSubmission = message.batchSubmission;
|
|
620
658
|
let stream = null;
|
|
621
659
|
let semanticStream = false;
|
|
660
|
+
// Thinking streams accept the same delivery-block contract as semantic
|
|
661
|
+
// streams: finish() must receive { text, format } so a markdown answer
|
|
662
|
+
// renders rich instead of falling back to plain text.
|
|
663
|
+
let thinkingStream = false;
|
|
622
664
|
// A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
|
|
623
665
|
// private-chat Rich Draft) visible during long silent stretches such as a
|
|
624
666
|
// running tool call. Declared outside the try so every exit path (including
|
|
@@ -689,29 +731,51 @@ export class TextHarnessBridge {
|
|
|
689
731
|
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
|
690
732
|
});
|
|
691
733
|
let streamFinished = false;
|
|
692
|
-
if (typeof this.#bot.
|
|
734
|
+
if (this.#thinkingTraces && typeof this.#bot.openThinkingStream === 'function') {
|
|
693
735
|
try {
|
|
694
|
-
stream = await this.#bot.
|
|
695
|
-
|
|
736
|
+
stream = await this.#bot.openThinkingStream(target);
|
|
737
|
+
thinkingStream = true;
|
|
696
738
|
} catch (error) {
|
|
739
|
+
stream = null;
|
|
697
740
|
this.#logger.warn?.(
|
|
698
|
-
`[dsh-im:${this.#descriptor.key}] unable to start a
|
|
741
|
+
`[dsh-im:${this.#descriptor.key}] unable to start a thinking stream; using final delivery:`,
|
|
699
742
|
error,
|
|
700
743
|
);
|
|
701
744
|
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
745
|
+
}
|
|
746
|
+
if (!stream) {
|
|
747
|
+
if (typeof this.#bot.openDeliveryStream === 'function') {
|
|
748
|
+
try {
|
|
749
|
+
stream = await this.#bot.openDeliveryStream(target);
|
|
750
|
+
semanticStream = true;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
this.#logger.warn?.(
|
|
753
|
+
`[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
|
|
754
|
+
error,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
} else if (typeof this.#bot.openStream === 'function') {
|
|
758
|
+
try {
|
|
759
|
+
stream = await this.#bot.openStream(target);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
this.#logger.warn?.(
|
|
762
|
+
`[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
|
|
763
|
+
error,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
710
766
|
}
|
|
711
767
|
}
|
|
768
|
+
const thinkingMode = Boolean(stream
|
|
769
|
+
&& this.#thinkingTraces
|
|
770
|
+
&& typeof stream.sendToolTrace === 'function');
|
|
771
|
+
// What the model sees. A message may carry two texts: `controlText`, the
|
|
772
|
+
// plain body the parsers read, and `content`, the same body decorated for
|
|
773
|
+
// the model — the email channel prefixes the mail headers there, so a
|
|
774
|
+
// subject-only instruction is not silently dropped. Falling back to the
|
|
775
|
+
// parsed text kept commands working but lost that decoration entirely.
|
|
712
776
|
let content = hasImages || hasReply
|
|
713
|
-
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
714
|
-
: undefined;
|
|
777
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
778
|
+
: cleanText(message.content) || undefined;
|
|
715
779
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
716
780
|
let contextEnhanced = false;
|
|
717
781
|
if (snapshot) {
|
|
@@ -766,7 +830,24 @@ export class TextHarnessBridge {
|
|
|
766
830
|
timeoutMs: this.#replyTimeoutMs,
|
|
767
831
|
signal: this.#signal,
|
|
768
832
|
control: { owner: this, key: conversationKey },
|
|
833
|
+
// Thinking mode needs every update (reasoning + tool calls) in
|
|
834
|
+
// order; latest mode filters as before. Only thinking mode subscribes
|
|
835
|
+
// to reasoning updates, so default-mode consumers on other channels
|
|
836
|
+
// never see them (their progress handlers would render update.text).
|
|
837
|
+
progressMode: thinkingMode ? 'all' : undefined,
|
|
838
|
+
reasoning: thinkingMode,
|
|
769
839
|
onUpdate: stream ? async (update) => {
|
|
840
|
+
if (thinkingMode) {
|
|
841
|
+
// Only the 💭 and 🔧 lines are surfaced; status/text updates
|
|
842
|
+
// and the assistant-message canonical text are not.
|
|
843
|
+
if (update.type === 'reasoning') {
|
|
844
|
+
await stream.sendThinking(update.text);
|
|
845
|
+
} else if (update.type === 'tool') {
|
|
846
|
+
await stream.sendToolTrace(update.name, update.arguments);
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (update.type === 'reasoning') return;
|
|
770
851
|
const progress = update.type === 'text' ? update.text
|
|
771
852
|
: update.type === 'tool' ? t('正在使用{name}…', { name: update.name }) : update.text;
|
|
772
853
|
if (progress) {
|
|
@@ -790,6 +871,7 @@ export class TextHarnessBridge {
|
|
|
790
871
|
},
|
|
791
872
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
792
873
|
files: message.files,
|
|
874
|
+
images: message.images,
|
|
793
875
|
},
|
|
794
876
|
});
|
|
795
877
|
stopKeepalive();
|
|
@@ -805,7 +887,7 @@ export class TextHarnessBridge {
|
|
|
805
887
|
let textReceipt = null;
|
|
806
888
|
if (stream) {
|
|
807
889
|
try {
|
|
808
|
-
const result = await stream.finish(semanticStream
|
|
890
|
+
const result = await stream.finish(semanticStream || thinkingStream
|
|
809
891
|
? createTextDeliveryBlock(visibleAnswer, answerFormat)
|
|
810
892
|
: visibleAnswer);
|
|
811
893
|
streamFinished = true;
|
|
@@ -877,6 +959,8 @@ export class TextHarnessBridge {
|
|
|
877
959
|
clearLastMessageFailure(this.#status);
|
|
878
960
|
}
|
|
879
961
|
}
|
|
962
|
+
// The turn produced an answer, so its retry budget is spent and reset.
|
|
963
|
+
this.#deliveryAttempts.delete(messageId);
|
|
880
964
|
return delivery.receipt;
|
|
881
965
|
} catch (error) {
|
|
882
966
|
stopKeepalive();
|
|
@@ -934,7 +1018,7 @@ export class TextHarnessBridge {
|
|
|
934
1018
|
: messageFailureText(failure);
|
|
935
1019
|
this.#logger.error?.(
|
|
936
1020
|
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
|
|
937
|
-
error,
|
|
1021
|
+
messageFailureDiagnostic(error, failure),
|
|
938
1022
|
);
|
|
939
1023
|
if (await presentStreamFailure(failureText)) {
|
|
940
1024
|
return error.deliveryReceipt;
|
|
@@ -948,6 +1032,10 @@ export class TextHarnessBridge {
|
|
|
948
1032
|
sendError,
|
|
949
1033
|
);
|
|
950
1034
|
}
|
|
1035
|
+
// The turn produced no answer, so this id is "attempted", not "handled".
|
|
1036
|
+
// Releasing the mark lets the next poll retry it; `alreadyRecorded` means
|
|
1037
|
+
// an outer caller owns the mark, so it is left alone there.
|
|
1038
|
+
if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
|
|
951
1039
|
return error.deliveryReceipt;
|
|
952
1040
|
} finally {
|
|
953
1041
|
stopKeepalive();
|
|
@@ -958,6 +1046,43 @@ export class TextHarnessBridge {
|
|
|
958
1046
|
}
|
|
959
1047
|
}
|
|
960
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* Release the `markSeen` of a turn that failed without delivering anything,
|
|
1051
|
+
* so the next poll can pick the message up again.
|
|
1052
|
+
*
|
|
1053
|
+
* Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
|
|
1054
|
+
* which the mark stays and the id becomes a tombstone. That keeps a mail that
|
|
1055
|
+
* always fails from being re-executed forever, while a transient Harness
|
|
1056
|
+
* outage no longer drops it permanently.
|
|
1057
|
+
*
|
|
1058
|
+
* Returns true when the message was released and will be retried.
|
|
1059
|
+
*/
|
|
1060
|
+
async #releaseFailedDelivery(messageId) {
|
|
1061
|
+
const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
|
|
1062
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1063
|
+
// Give up: keep the mark so the id is never replayed, and stop tracking
|
|
1064
|
+
// it. The sender has already been told the turn failed.
|
|
1065
|
+
this.#deliveryAttempts.delete(messageId);
|
|
1066
|
+
this.#logger.warn?.(
|
|
1067
|
+
`[dsh-im:${this.#descriptor.key}] giving up on a message after `
|
|
1068
|
+
+ `${attempts} failed delivery attempts; it will not be retried.`,
|
|
1069
|
+
);
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
this.#deliveryAttempts.set(messageId, attempts);
|
|
1073
|
+
if (typeof this.#state?.unmarkSeen !== 'function') return false;
|
|
1074
|
+
try {
|
|
1075
|
+
await this.#state.unmarkSeen(messageId);
|
|
1076
|
+
return true;
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
this.#logger.warn?.(
|
|
1079
|
+
`[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
|
|
1080
|
+
error,
|
|
1081
|
+
);
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
961
1086
|
/**
|
|
962
1087
|
* Advance the pending interaction with one answer.
|
|
963
1088
|
*
|
|
@@ -1000,7 +1125,7 @@ export class TextHarnessBridge {
|
|
|
1000
1125
|
}
|
|
1001
1126
|
|
|
1002
1127
|
const target = message.replyTarget;
|
|
1003
|
-
const text =
|
|
1128
|
+
const text = controlTextOf(message);
|
|
1004
1129
|
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
|
|
1005
1130
|
try {
|
|
1006
1131
|
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;
|