@xmanrui/dsh-im 4.34.1 → 4.35.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 -1
- package/README.md +5 -1
- package/lib/client.js +306 -201
- package/lib/index.js +291 -290
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +1 -1
- package/plugin-src/client/bot-settings-tabs.js +114 -0
- package/plugin-src/client/delivery-settings.js +2 -16
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/styles.js +8 -3
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -1
- package/plugin-src/host/channels/shared/access-policy-production.mjs +13 -0
- package/plugin-src/host/delivery-adapter.mjs +2 -2
- package/plugin-src/host/delivery-service.mjs +13 -4
- package/plugin-src/host/index.mjs +1 -0
- package/plugin-src/host/modern-harness-api.mjs +2 -1
- package/src/channels/feishu/bridge.mjs +34 -18
- package/src/channels/feishu/feishu-cards.mjs +58 -6
- package/src/channels/feishu/feishu-channel.mjs +11 -3
- package/src/channels/feishu/feishu-runtime.mjs +19 -4
- package/src/channels/feishu/multi-bot-controller.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +8 -8
- package/src/channels/qq/qq-mention.mjs +37 -0
- package/src/channels/qq/qq-runtime.mjs +11 -3
- package/src/channels/shared/bot-workspace-store.mjs +1 -0
- package/src/channels/shared/connection-error.mjs +10 -4
- package/src/channels/shared/harness-client.mjs +9 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
- package/src/channels/shared/message-failure.mjs +26 -4
- package/src/channels/shared/text-harness-bridge.mjs +2 -2
- package/src/channels/shared/workspace-command.mjs +28 -16
|
@@ -80,12 +80,13 @@ function abortReason(signal) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function operationTimeout(stage) {
|
|
83
|
-
const error = new Error(`Feishu
|
|
83
|
+
const error = new Error(`Feishu ${stage} timed out.`);
|
|
84
84
|
error.code = 'provider-timeout';
|
|
85
85
|
return error;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// Bound the caller's wait even when the SDK does not honor cancellation.
|
|
89
|
+
export function waitForFeishuOperation(operation, { signal, timeoutMs, stage = 'request' }) {
|
|
89
90
|
signal?.throwIfAborted();
|
|
90
91
|
const deadline = new AbortController();
|
|
91
92
|
const operationSignal = signal
|
|
@@ -108,7 +109,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
|
|
108
109
|
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
109
110
|
operationSignal.addEventListener('abort', onAbort, { once: true });
|
|
110
111
|
|
|
111
|
-
Promise.resolve().then(() =>
|
|
112
|
+
Promise.resolve().then(() => {
|
|
113
|
+
operationSignal.throwIfAborted();
|
|
114
|
+
return operation(operationSignal);
|
|
115
|
+
}).then(
|
|
112
116
|
(value) => finish(resolve, value),
|
|
113
117
|
(error) => finish(reject, error),
|
|
114
118
|
);
|
|
@@ -116,6 +120,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
function waitForFileOperation(operation, { stage, ...options }) {
|
|
124
|
+
return waitForFeishuOperation(operation, { ...options, stage: `file ${stage}` });
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
function deliveryUuid(file, chatId, messageType) {
|
|
120
128
|
const seed = `${file.deliveryKey}\u0000${chatId}`;
|
|
121
129
|
const digest = createHash('sha256')
|
|
@@ -2,7 +2,7 @@ import { createConnectionDiagnostics, atConnectionStage } from '../shared/connec
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
4
4
|
import { cardActionProbeCard } from './feishu-cards.mjs';
|
|
5
|
-
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
5
|
+
import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
|
|
6
6
|
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
7
7
|
import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
|
|
8
8
|
import { createVoice } from './voice.mjs';
|
|
@@ -367,6 +367,7 @@ export class FeishuRuntime {
|
|
|
367
367
|
voice: this.#voice,
|
|
368
368
|
sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
|
|
369
369
|
repair: this.#repair,
|
|
370
|
+
requestTimeoutMs: this.#requestTimeoutMs,
|
|
370
371
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
371
372
|
// Interaction cards (approval/question buttons) are on by default.
|
|
372
373
|
// Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
|
|
@@ -674,7 +675,7 @@ export class FeishuRuntime {
|
|
|
674
675
|
});
|
|
675
676
|
}
|
|
676
677
|
|
|
677
|
-
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
678
|
+
async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
|
|
678
679
|
if (!this.#status.ready || !this.#client) {
|
|
679
680
|
const error = new Error('飞书机器人尚未连接');
|
|
680
681
|
error.code = 'bot-not-connected';
|
|
@@ -697,26 +698,40 @@ export class FeishuRuntime {
|
|
|
697
698
|
error.code = 'bad-request';
|
|
698
699
|
throw error;
|
|
699
700
|
}
|
|
701
|
+
if (typeof receipt !== 'boolean' || (receipt && target.kind !== 'group'))
|
|
702
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
700
703
|
signal?.throwIfAborted();
|
|
701
704
|
// Use the same native Markdown element as chat, without opening a stream
|
|
702
705
|
// or retrying as plain text after a possibly accepted delivery.
|
|
703
706
|
const content = format === 'markdown'
|
|
704
707
|
? { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } }
|
|
705
708
|
: { text };
|
|
706
|
-
const
|
|
709
|
+
const activeSignal = signal
|
|
710
|
+
? AbortSignal.any([signal, this.#abortController.signal])
|
|
711
|
+
: this.#abortController.signal;
|
|
712
|
+
const response = await waitForFeishuOperation(() => this.#client.im.v1.message.create({
|
|
707
713
|
params: { receive_id_type: receiveIdType },
|
|
708
714
|
data: {
|
|
709
715
|
receive_id: receiveId,
|
|
710
716
|
msg_type: format === 'markdown' ? 'interactive' : 'text',
|
|
711
717
|
content: JSON.stringify(content),
|
|
712
718
|
},
|
|
719
|
+
}), {
|
|
720
|
+
signal: activeSignal,
|
|
721
|
+
timeoutMs: this.#requestTimeoutMs,
|
|
722
|
+
stage: `proactive text send (${this.#requestTimeoutMs}ms)`,
|
|
713
723
|
});
|
|
714
724
|
if (response?.code && response.code !== 0) {
|
|
715
725
|
const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
|
|
716
726
|
error.code = 'target-rejected';
|
|
717
727
|
throw error;
|
|
718
728
|
}
|
|
719
|
-
return { sent: true };
|
|
729
|
+
if (!receipt) return { sent: true };
|
|
730
|
+
const messageId = response?.data?.message_id;
|
|
731
|
+
const conversationId = response?.data?.chat_id;
|
|
732
|
+
if (typeof messageId !== 'string' || !messageId || messageId.length > 512 || conversationId !== receiveId)
|
|
733
|
+
throw Object.assign(new Error('send-result-unknown'), { code: 'send-result-unknown' });
|
|
734
|
+
return { sent: true, receipt: { version: 1, messageId, conversationId } };
|
|
720
735
|
}
|
|
721
736
|
|
|
722
737
|
/**
|
|
@@ -594,7 +594,7 @@ export class MultiBotDshFeishuController {
|
|
|
594
594
|
this.#assertOpen('capability-unavailable');
|
|
595
595
|
return { version: 1, botId, channel: 'feishu', account,
|
|
596
596
|
connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
|
|
597
|
-
capabilities: ['proactive-text-checked'] };
|
|
597
|
+
capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
|
|
598
598
|
});
|
|
599
599
|
}
|
|
600
600
|
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from './qq-menu.mjs';
|
|
76
76
|
import { withSessionBindingLock } from '../shared/session-binding-lock.mjs';
|
|
77
77
|
import { t } from '../shared/i18n.mjs';
|
|
78
|
+
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
78
79
|
|
|
79
80
|
function interactionResolvedText() {
|
|
80
81
|
return t('这个问题已在其他客户端处理,无需再次回答。');
|
|
@@ -408,7 +409,7 @@ async function sendQqFile(
|
|
|
408
409
|
function canClaimInteractionReply(message, pending) {
|
|
409
410
|
return pending.questions[pending.index]
|
|
410
411
|
&& nonEmptyString(message?.senderId) === pending.actor
|
|
411
|
-
&& (message
|
|
412
|
+
&& isQqMessageAddressed(message)
|
|
412
413
|
&& !hasQqImageAttachments(message)
|
|
413
414
|
&& !hasQqFileAttachments(message)
|
|
414
415
|
&& nonEmptyString(safeText(message));
|
|
@@ -506,9 +507,9 @@ export class QqHarnessBridge {
|
|
|
506
507
|
|| !['c2c', 'group'].includes(message?.kind)
|
|
507
508
|
|| this.#state.hasSeen(messageId)
|
|
508
509
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
510
|
+
message = normalizeQqMentions(message, new Set([this.#bot.appId]));
|
|
509
511
|
const key = conversationKey(message);
|
|
510
|
-
const addressed = message
|
|
511
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
512
|
+
const addressed = isQqMessageAddressed(message);
|
|
512
513
|
const commandText = safeText(message);
|
|
513
514
|
const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
|
|
514
515
|
&& !qqReplyReference(message);
|
|
@@ -634,7 +635,7 @@ export class QqHarnessBridge {
|
|
|
634
635
|
actor: sender,
|
|
635
636
|
messageId,
|
|
636
637
|
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
|
|
637
|
-
addressed
|
|
638
|
+
addressed,
|
|
638
639
|
hasPendingQuestion: Boolean(pending),
|
|
639
640
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
640
641
|
? pending.queue
|
|
@@ -689,8 +690,7 @@ export class QqHarnessBridge {
|
|
|
689
690
|
alreadyRecorded = false,
|
|
690
691
|
batchSubmission = null,
|
|
691
692
|
} = {}) {
|
|
692
|
-
const addressed = message
|
|
693
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
693
|
+
const addressed = isQqMessageAddressed(message);
|
|
694
694
|
const preparedMessage = addressed
|
|
695
695
|
? prefetchInboundFiles(
|
|
696
696
|
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
|
|
@@ -984,7 +984,7 @@ export class QqHarnessBridge {
|
|
|
984
984
|
await this.#state.markSeen(messageId);
|
|
985
985
|
messageRecorded = true;
|
|
986
986
|
};
|
|
987
|
-
if (message
|
|
987
|
+
if (!isQqMessageAddressed(message)) return;
|
|
988
988
|
|
|
989
989
|
const target = message.replyTarget;
|
|
990
990
|
const promptMessage = preparedMessage
|
|
@@ -1245,7 +1245,7 @@ export class QqHarnessBridge {
|
|
|
1245
1245
|
this.#status.messagesReceived += 1;
|
|
1246
1246
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
1247
1247
|
|
|
1248
|
-
if (message
|
|
1248
|
+
if (!isQqMessageAddressed(message)) return;
|
|
1249
1249
|
const text = nonEmptyString(safeText(message));
|
|
1250
1250
|
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
|
|
1251
1251
|
await this.#bot.sendText(message.replyTarget, t('请用文字回答当前问题。'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Keep the decision after the SDK sanitizes the mention out of the content.
|
|
2
|
+
const addressedToBot = Symbol('qq.addressedToBot');
|
|
3
|
+
|
|
4
|
+
function selfMentions(message) {
|
|
5
|
+
return Array.isArray(message?.mentions)
|
|
6
|
+
? message.mentions.filter((mention) => mention?.is_you === true)
|
|
7
|
+
: [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isQqMessageAddressed(message) {
|
|
11
|
+
return Boolean(message) && (message.kind !== 'group'
|
|
12
|
+
|| message[addressedToBot] === true
|
|
13
|
+
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE'
|
|
14
|
+
|| selfMentions(message).length > 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** botIds belongs to one runtime; only platform-marked self mentions add IDs. */
|
|
18
|
+
export function normalizeQqMentions(message, botIds = new Set()) {
|
|
19
|
+
if (message?.kind !== 'group') return message;
|
|
20
|
+
for (const mention of selfMentions(message)) {
|
|
21
|
+
for (const id of [mention.id, mention.user_openid, mention.member_openid]) {
|
|
22
|
+
if (typeof id === 'string' && id.trim()) botIds.add(id.trim());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const marker = /<@!?([^<>\s]+)>/gu;
|
|
26
|
+
const content = typeof message.content === 'string' ? message.content : '';
|
|
27
|
+
// raw.content is still the current message, never the quoted message snapshot.
|
|
28
|
+
const rawContent = typeof message.raw?.content === 'string' ? message.raw.content : '';
|
|
29
|
+
const mentioned = isQqMessageAddressed(message)
|
|
30
|
+
|| [...`${content}\n${rawContent}`.matchAll(marker)].some((match) => botIds.has(match[1]));
|
|
31
|
+
// Match the SDK sanitizer's in-place contract: later sender enrichment must
|
|
32
|
+
// remain visible to the existing queued message and source-context capture.
|
|
33
|
+
message[addressedToBot] = mentioned;
|
|
34
|
+
message.content = content.replace(/<@!?([^<>\s]+)>[ \t]*/gu,
|
|
35
|
+
(match, id) => botIds.has(id) ? '' : match).trim();
|
|
36
|
+
return message;
|
|
37
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { t } from '../shared/i18n.mjs';
|
|
9
9
|
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
|
|
10
10
|
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
|
11
|
+
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
11
12
|
|
|
12
13
|
function timeoutError() {
|
|
13
14
|
const error = new Error('QQ WebSocket did not become ready in time');
|
|
@@ -178,6 +179,11 @@ export class QqRuntime {
|
|
|
178
179
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
179
180
|
signal: controller.signal,
|
|
180
181
|
});
|
|
182
|
+
const botMentionIds = new Set([this.#config.appId]);
|
|
183
|
+
bot.use(async (ctx, next) => {
|
|
184
|
+
ctx.message = normalizeQqMentions(ctx.message, botMentionIds);
|
|
185
|
+
await next();
|
|
186
|
+
});
|
|
181
187
|
// QQ delivers emoji/face messages as opaque `<faceType=..,faceId="..",ext="..">`
|
|
182
188
|
// tags. Parse them into readable text so the Harness sees what the sender
|
|
183
189
|
// actually meant instead of an unusable markup fragment.
|
|
@@ -186,8 +192,7 @@ export class QqRuntime {
|
|
|
186
192
|
keepAlive: true,
|
|
187
193
|
predicate: (ctx) => {
|
|
188
194
|
const message = ctx?.message;
|
|
189
|
-
if (!
|
|
190
|
-
&& message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
|
|
195
|
+
if (!isQqMessageAddressed(message)) return false;
|
|
191
196
|
if (!this.#accessPolicy) {
|
|
192
197
|
return message.kind === 'group'
|
|
193
198
|
|| this.#config.ownerUserOpenid === '*'
|
|
@@ -207,7 +212,10 @@ export class QqRuntime {
|
|
|
207
212
|
readyResolve = resolve;
|
|
208
213
|
readyReject = reject;
|
|
209
214
|
});
|
|
210
|
-
const onReady = () => {
|
|
215
|
+
const onReady = (data) => {
|
|
216
|
+
if (typeof data?.user?.id === 'string' && data.user.id.trim()) {
|
|
217
|
+
botMentionIds.add(data.user.id.trim());
|
|
218
|
+
}
|
|
211
219
|
const now = Date.now();
|
|
212
220
|
this.#status.ready = true;
|
|
213
221
|
this.#status.qqConnectionState = 'connected';
|
|
@@ -1709,6 +1709,7 @@ export function createBotWorkspaceScope(
|
|
|
1709
1709
|
};
|
|
1710
1710
|
}
|
|
1711
1711
|
if ((property === 'listWorkspaces'
|
|
1712
|
+
|| property === 'listWorkspaceEntries'
|
|
1712
1713
|
|| property === 'listWorkspaceSessions'
|
|
1713
1714
|
|| property === 'listModels')
|
|
1714
1715
|
&& typeof target[property] === 'function') {
|
|
@@ -6,7 +6,7 @@ import { t } from './i18n.mjs';
|
|
|
6
6
|
const reported = new WeakMap();
|
|
7
7
|
const lastReport = new WeakMap();
|
|
8
8
|
const stages = new WeakMap();
|
|
9
|
-
const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
|
|
9
|
+
const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
|
|
10
10
|
const STANDARD_NAMES = new Set(['Error', 'TypeError', 'SyntaxError', 'AggregateError', 'DOMException', 'AxiosError', 'TimeoutError', 'AbortError']);
|
|
11
11
|
const KNOWN_MESSAGES = {
|
|
12
12
|
'discord-401': 'Discord Bot Token 无效,请重新填写。',
|
|
@@ -37,18 +37,24 @@ export function extractConnectionEvidence(error) {
|
|
|
37
37
|
const { chain, truncated } = connectionErrorChain(error);
|
|
38
38
|
const errors = chain.map(e => ({
|
|
39
39
|
type: STANDARD_NAMES.has(e.name) ? e.name : 'Error',
|
|
40
|
+
// Host RPC adapters carry safe diagnostic facts without the original Error graph.
|
|
40
41
|
...normalizeDiagnosticDetails({
|
|
41
|
-
reason:
|
|
42
|
+
reason: e.details?.reason, reasons: e.details?.reasons,
|
|
43
|
+
httpStatus: e.details?.httpStatus, providerCode: e.details?.providerCode,
|
|
44
|
+
}),
|
|
45
|
+
...normalizeDiagnosticDetails({
|
|
46
|
+
// DOMException TimeoutError has a numeric legacy code; its name is authoritative.
|
|
47
|
+
reason: OWNED_REASONS[e.code] ?? (e.name === 'TimeoutError' ? 'timeout' : e.code ?? (e instanceof SyntaxError ? 'invalid-json' : undefined)),
|
|
42
48
|
httpStatus: e.status ?? e.statusCode ?? e.response?.status,
|
|
43
49
|
providerCode: e.providerCode,
|
|
44
50
|
}),
|
|
45
51
|
hasCause: Boolean(e.cause && typeof e.cause === 'object'),
|
|
46
52
|
hasErrors: Array.isArray(e.errors),
|
|
47
53
|
}));
|
|
48
|
-
const allReasons = [...new Set(errors.
|
|
54
|
+
const allReasons = [...new Set(errors.flatMap(e => e.reason === 'multiple-causes' ? e.reasons ?? [] : [e.reason]).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
|
|
49
55
|
const precise = allReasons.filter(r => !['timeout', 'http-error', 'provider-rejected', 'invalid-response'].includes(r));
|
|
50
56
|
const reasons = precise.length ? precise : allReasons;
|
|
51
|
-
const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] })[field]).find(v => v !== undefined);
|
|
57
|
+
const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
|
|
52
58
|
return {
|
|
53
59
|
chain, errors,
|
|
54
60
|
details: normalizeDiagnosticDetails({
|
|
@@ -242,10 +242,12 @@ function turnStoppedError() {
|
|
|
242
242
|
return error;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
function
|
|
245
|
+
function workspaceEntries(value) {
|
|
246
246
|
if (!Array.isArray(value?.items)) return [];
|
|
247
247
|
return value.items.flatMap((item) => (
|
|
248
|
-
typeof item?.path === 'string' && isAbsolute(item.path)
|
|
248
|
+
typeof item?.path === 'string' && isAbsolute(item.path)
|
|
249
|
+
? [{ path: item.path, title: typeof item.title === 'string' ? item.title : '' }]
|
|
250
|
+
: []
|
|
249
251
|
));
|
|
250
252
|
}
|
|
251
253
|
|
|
@@ -1081,8 +1083,12 @@ export class HarnessClient {
|
|
|
1081
1083
|
}
|
|
1082
1084
|
|
|
1083
1085
|
async listWorkspaces(options = {}) {
|
|
1086
|
+
return (await this.listWorkspaceEntries(options)).map((item) => item.path);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async listWorkspaceEntries(options = {}) {
|
|
1084
1090
|
await this.ensureRunning(options);
|
|
1085
|
-
return
|
|
1091
|
+
return workspaceEntries(await this.rpc('workspace.list', {}, 30_000, options));
|
|
1086
1092
|
}
|
|
1087
1093
|
|
|
1088
1094
|
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
@@ -104,6 +104,10 @@ export default {
|
|
|
104
104
|
'This message contains an image or file that cannot be processed. Adjust it and send it again.',
|
|
105
105
|
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
|
|
106
106
|
'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
|
|
107
|
+
'请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
|
|
108
|
+
'The request timed out and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
|
|
109
|
+
'网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
|
|
110
|
+
'The network request failed and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
|
|
107
111
|
'错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
|
|
108
112
|
'{label}机器人': '{label} bot',
|
|
109
113
|
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractConnectionEvidence } from './connection-error.mjs';
|
|
1
|
+
import { connectionHint, extractConnectionEvidence } from './connection-error.mjs';
|
|
2
2
|
import { diagnosticFields } from './diagnostic-details.mjs';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
@@ -90,6 +90,10 @@ const FAILURE_MESSAGES = Object.freeze({
|
|
|
90
90
|
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
|
|
91
91
|
INPUT_INVALID:
|
|
92
92
|
'当前消息包含无法处理的图片或文件。请调整后重新发送。',
|
|
93
|
+
REQUEST_TIMEOUT:
|
|
94
|
+
'请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
|
|
95
|
+
NETWORK_ERROR:
|
|
96
|
+
'网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
|
|
93
97
|
INTERNAL_UNKNOWN:
|
|
94
98
|
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
|
|
95
99
|
});
|
|
@@ -156,16 +160,30 @@ function failureCode(error) {
|
|
|
156
160
|
return 'CHANNEL_RATE_LIMIT';
|
|
157
161
|
}
|
|
158
162
|
if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
|
|
159
|
-
.includes(code)) {
|
|
163
|
+
.includes(code) || error?.deliveryOutcome === 'unknown') {
|
|
160
164
|
return 'CHANNEL_DELIVERY_UNCERTAIN';
|
|
161
165
|
}
|
|
162
166
|
if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
|
|
163
|
-
||
|
|
167
|
+
|| code === 'network-error') {
|
|
164
168
|
return 'CHANNEL_DELIVERY';
|
|
165
169
|
}
|
|
166
170
|
return 'INTERNAL_UNKNOWN';
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
// Only refine the unknown fallback, using the same allowlisted evidence as the UI.
|
|
174
|
+
// Mixed unrelated causes and cancellation do not establish a network diagnosis.
|
|
175
|
+
function transportFailureCode(error, details) {
|
|
176
|
+
if (error?.name === 'AbortError' || error?.code === 'cancelled') return 'INTERNAL_UNKNOWN';
|
|
177
|
+
const reasons = details.reason === 'multiple-causes' ? details.reasons : [details.reason];
|
|
178
|
+
const timeout = reason => reason === 'timeout' || reason === 'ETIMEDOUT' || /_TIMEOUT$/u.test(reason);
|
|
179
|
+
if (reasons?.length && reasons.every(timeout)) return 'REQUEST_TIMEOUT';
|
|
180
|
+
const network = reason => timeout(reason) || [
|
|
181
|
+
'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET',
|
|
182
|
+
'ENETUNREACH', 'EHOSTUNREACH', 'ERR_NETWORK_ACCESS_DENIED', 'ERR_PROXY_CONNECTION_FAILED',
|
|
183
|
+
].includes(reason) || /CERT|TLS|SSL|ISSUER|SIGNATURE/u.test(reason);
|
|
184
|
+
return reasons?.length && reasons.every(network) ? 'NETWORK_ERROR' : 'INTERNAL_UNKNOWN';
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
function safeReferenceId(value) {
|
|
170
188
|
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
|
|
171
189
|
? value
|
|
@@ -185,13 +203,17 @@ export function classifyMessageFailure(error, {
|
|
|
185
203
|
} = {}) {
|
|
186
204
|
const safeReason = safeFailureReason(reason);
|
|
187
205
|
const classifiedCode = failureCode(error);
|
|
188
|
-
const
|
|
206
|
+
const businessCode = classifiedCode === 'INTERNAL_UNKNOWN'
|
|
189
207
|
&& safeReason
|
|
190
208
|
&& typeof userMessage === 'string'
|
|
191
209
|
&& userMessage.trim()
|
|
192
210
|
? 'INPUT_INVALID'
|
|
193
211
|
: classifiedCode;
|
|
194
212
|
const details = extractConnectionEvidence(error).details;
|
|
213
|
+
const code = businessCode === 'INTERNAL_UNKNOWN'
|
|
214
|
+
? transportFailureCode(error, details)
|
|
215
|
+
: businessCode;
|
|
216
|
+
if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
|
|
195
217
|
return Object.freeze({
|
|
196
218
|
...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
|
|
197
219
|
code,
|
|
@@ -936,12 +936,12 @@ export class TextHarnessBridge {
|
|
|
936
936
|
const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
|
|
937
937
|
if (textReceipt?.deliveryOutcome === 'failed') {
|
|
938
938
|
textDeliveryError = channelDeliveryFailure(
|
|
939
|
-
new Error(
|
|
939
|
+
Object.assign(new Error('Final text delivery failed'), { code: textReceipt.reason }),
|
|
940
940
|
{ uncertain: false },
|
|
941
941
|
);
|
|
942
942
|
} else if (finalDeliveryUnknown) {
|
|
943
943
|
textDeliveryError = channelDeliveryFailure(
|
|
944
|
-
new Error(
|
|
944
|
+
Object.assign(new Error('Final text delivery outcome is unknown'), { code: textReceipt.reason }),
|
|
945
945
|
);
|
|
946
946
|
}
|
|
947
947
|
// A failed final text must not discard an already registered result file.
|
|
@@ -62,17 +62,19 @@ export function parseSessionListArgument(value) {
|
|
|
62
62
|
return { selector: '', limit };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
async function existingWorkspacePath(value) {
|
|
66
|
+
const workspace = normalizedWorkspacePath(value);
|
|
67
|
+
if (!workspace) return null;
|
|
68
|
+
try {
|
|
69
|
+
if (!(await stat(workspace)).isDirectory()) return null;
|
|
70
|
+
return normalizedWorkspacePath(await realpath(workspace));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
async function existingWorkspacePaths(values) {
|
|
66
|
-
const checked = await Promise.all(values.map(
|
|
67
|
-
const workspace = normalizedWorkspacePath(value);
|
|
68
|
-
if (!workspace) return null;
|
|
69
|
-
try {
|
|
70
|
-
if (!(await stat(workspace)).isDirectory()) return null;
|
|
71
|
-
return normalizedWorkspacePath(await realpath(workspace));
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}));
|
|
77
|
+
const checked = await Promise.all(values.map(existingWorkspacePath));
|
|
76
78
|
return [...new Set(checked.filter(Boolean))];
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -109,16 +111,26 @@ async function selectedWorkspacePath(value) {
|
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
export async function workspacePathSnapshot(harness, options = {}) {
|
|
113
|
-
const
|
|
114
|
+
export async function workspacePathSnapshot(harness, { includeTitles = false, ...options } = {}) {
|
|
115
|
+
const withEntries = includeTitles && typeof harness.listWorkspaceEntries === 'function';
|
|
116
|
+
const listed = await (withEntries ? harness.listWorkspaceEntries(options) : harness.listWorkspaces(options));
|
|
114
117
|
const currentValue = typeof harness?.currentWorkspace === 'function'
|
|
115
118
|
? harness.currentWorkspace()
|
|
116
119
|
: null;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
+
const current = await existingWorkspacePath(currentValue);
|
|
121
|
+
const entries = await Promise.all((Array.isArray(listed) ? listed : []).map(async (item) => ({
|
|
122
|
+
path: await existingWorkspacePath(withEntries ? item?.path : item),
|
|
123
|
+
title: withEntries ? safeDisplayText(item?.title) : '',
|
|
124
|
+
})));
|
|
125
|
+
const paths = [...new Set([...(current ? [current] : []), ...entries.map((item) => item.path).filter(Boolean)])];
|
|
126
|
+
const titles = {};
|
|
127
|
+
if (includeTitles) {
|
|
128
|
+
for (const item of entries) {
|
|
129
|
+
if (item.path && item.title && !titles[item.path]) titles[item.path] = item.title;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
120
132
|
harness.assertWorkspaceScope?.();
|
|
121
|
-
return { current: current ?? null, paths };
|
|
133
|
+
return { current: current ?? null, paths, ...(includeTitles ? { titles } : {}) };
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
export function splitWorkspaceCommandMessage(message) {
|