@xmanrui/dsh-im 4.20.2 → 4.21.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 +21 -9
- package/README.md +21 -9
- package/lib/client.js +108 -21
- package/lib/index.js +276 -276
- package/package.json +17 -1
- package/plugin-src/client/channel-logos.js +18 -5
- package/plugin-src/client/channels/imessage/styles.js +1 -1
- package/plugin-src/client/channels/slack/styles.js +1 -1
- package/plugin-src/client/channels/weixin/connection-error.js +4 -1
- package/plugin-src/client/i18n.js +8 -0
- package/plugin-src/client/model-setting.js +4 -2
- package/plugin-src/client/session-channel-logos.js +1 -2
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +2 -1
- package/plugin-src/host/modern-harness-api.mjs +91 -3
- package/scripts/verify-model-setting.mjs +4 -1
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +9 -1
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +9 -1
- package/src/channels/qq/qq-controller.mjs +11 -5
- package/src/channels/qq/state-error.mjs +17 -0
- package/src/channels/qq/state-store.mjs +35 -9
- package/src/channels/shared/batch-input.mjs +22 -2
- package/src/channels/shared/bot-workspace-store.mjs +46 -26
- package/src/channels/shared/config-read-error.mjs +24 -0
- package/src/channels/shared/harness-client.mjs +13 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/qq.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +198 -3
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +7 -1
- package/src/channels/slack/slack-runtime.mjs +3 -1
- package/src/channels/telegram/telegram-api.mjs +62 -2
- package/src/channels/telegram/telegram-bridge.mjs +66 -1
- package/src/channels/telegram/telegram-rich-message.mjs +6 -4
- package/src/channels/telegram/telegram-runtime.mjs +128 -6
- package/src/channels/wecom/wecom-bridge.mjs +4 -0
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
- package/src/channels/weixin/config-store.mjs +24 -15
- package/src/channels/weixin/connection-error.en.mjs +21 -0
- package/src/channels/weixin/connection-error.mjs +21 -6
- package/src/channels/weixin/diagnostic-details.mjs +24 -1
- package/src/channels/weixin/weixin-bridge.mjs +1 -0
|
@@ -42,10 +42,6 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze(
|
|
|
42
42
|
commandMenuEntries(SHARED_COMMAND_CATALOG, (text) => text).map((item) => Object.freeze(item)),
|
|
43
43
|
);
|
|
44
44
|
|
|
45
|
-
function escaped(value) {
|
|
46
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
45
|
function mentionedUsername(message, username) {
|
|
50
46
|
if (!username) return false;
|
|
51
47
|
return [
|
|
@@ -60,9 +56,27 @@ function mentionedUsername(message, username) {
|
|
|
60
56
|
}));
|
|
61
57
|
}
|
|
62
58
|
|
|
59
|
+
function isUsernameBoundary(character) {
|
|
60
|
+
return character === undefined || !(/[a-z0-9_]/i).test(character);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
63
|
function withoutBotMention(text, username) {
|
|
64
64
|
if (!username || typeof text !== 'string') return text;
|
|
65
|
-
|
|
65
|
+
const target = `@${username}`.toLowerCase();
|
|
66
|
+
let result = '';
|
|
67
|
+
let offset = 0;
|
|
68
|
+
while (offset < text.length) {
|
|
69
|
+
// Case-fold only the candidate: Unicode casing can change the full text's length.
|
|
70
|
+
if (text[offset] === '@'
|
|
71
|
+
&& text.slice(offset, offset + target.length).toLowerCase() === target
|
|
72
|
+
&& isUsernameBoundary(text[offset + target.length])) {
|
|
73
|
+
offset += target.length;
|
|
74
|
+
} else {
|
|
75
|
+
result += text[offset];
|
|
76
|
+
offset += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result.trim();
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
@@ -210,6 +224,56 @@ function telegramReplyReference(message, { quote, loadReplyContent } = {}) {
|
|
|
210
224
|
};
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Normalize an inline-keyboard press into the same shape the bridge already
|
|
229
|
+
* consumes for messages, so a button reuses the existing conversation key,
|
|
230
|
+
* access policy, and pending-interaction routing instead of a parallel path.
|
|
231
|
+
* A press always counts as addressed: the user acted on a message the bot sent.
|
|
232
|
+
*/
|
|
233
|
+
export function normalizeTelegramCallback(update, { botId } = {}) {
|
|
234
|
+
const callback = update?.callback_query;
|
|
235
|
+
const message = callback?.message;
|
|
236
|
+
const chatId = message?.chat?.id;
|
|
237
|
+
const senderId = callback?.from?.id;
|
|
238
|
+
const providerMessageId = message?.message_id;
|
|
239
|
+
if (!Number.isSafeInteger(update?.update_id)
|
|
240
|
+
|| typeof callback?.id !== 'string' || !callback.id
|
|
241
|
+
|| chatId === undefined || senderId === undefined
|
|
242
|
+
|| !Number.isSafeInteger(providerMessageId)) return null;
|
|
243
|
+
if (!['private', 'group', 'supergroup'].includes(message.chat?.type)) return null;
|
|
244
|
+
const direct = message.chat.type === 'private';
|
|
245
|
+
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
|
246
|
+
? message.message_thread_id : undefined;
|
|
247
|
+
const conversationId = messageThreadId === undefined
|
|
248
|
+
? String(chatId) : `${chatId}:${messageThreadId}`;
|
|
249
|
+
return {
|
|
250
|
+
messageId: String(update.update_id),
|
|
251
|
+
callbackQueryId: callback.id,
|
|
252
|
+
providerMessageId,
|
|
253
|
+
senderId: String(senderId),
|
|
254
|
+
senderIsBot: callback.from?.is_bot === true,
|
|
255
|
+
kind: direct ? 'direct' : 'group',
|
|
256
|
+
conversationId,
|
|
257
|
+
data: typeof callback.data === 'string' ? callback.data : '',
|
|
258
|
+
addressed: true,
|
|
259
|
+
replyTarget: {
|
|
260
|
+
chatId,
|
|
261
|
+
chatType: message.chat.type,
|
|
262
|
+
messageThreadId,
|
|
263
|
+
},
|
|
264
|
+
reactionTarget: { chatId, messageId: providerMessageId },
|
|
265
|
+
contextSource: () => ({
|
|
266
|
+
senderName: [callback.from?.first_name, callback.from?.last_name]
|
|
267
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
268
|
+
.map((value) => value.trim()).join(' ') || callback.from?.username,
|
|
269
|
+
conversationTitle: direct ? undefined : message.chat?.title,
|
|
270
|
+
chatId: String(chatId),
|
|
271
|
+
threadId: messageThreadId === undefined ? undefined : String(messageThreadId),
|
|
272
|
+
}),
|
|
273
|
+
...(botId === undefined ? {} : { botId }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
213
277
|
export function normalizeTelegramUpdate(update, {
|
|
214
278
|
botId,
|
|
215
279
|
username,
|
|
@@ -463,6 +527,46 @@ export class TelegramBotClient {
|
|
|
463
527
|
return { providerMessageIds };
|
|
464
528
|
}
|
|
465
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Deliver a card whose inline keyboard carries the interaction payloads.
|
|
532
|
+
* Kept separate from sendText so a markup failure can be caught and degraded
|
|
533
|
+
* to the plain-text flow by the caller without losing the text itself.
|
|
534
|
+
*/
|
|
535
|
+
async sendInteractionCard(target, { text, markup, replyToMessageId } = {}) {
|
|
536
|
+
const result = await this.#api.sendMessage({
|
|
537
|
+
chatId: target.chatId,
|
|
538
|
+
text,
|
|
539
|
+
replyToMessageId: replyToMessageId ?? target.replyToMessageId,
|
|
540
|
+
messageThreadId: target.messageThreadId,
|
|
541
|
+
replyMarkup: markup,
|
|
542
|
+
signal: this.#signal,
|
|
543
|
+
});
|
|
544
|
+
const providerMessageIds = [];
|
|
545
|
+
if (Number.isSafeInteger(result?.message_id)) {
|
|
546
|
+
providerMessageIds.push(String(result.message_id));
|
|
547
|
+
}
|
|
548
|
+
return { providerMessageIds };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Replace a delivered card's keyboard in place; an empty keyboard removes it. */
|
|
552
|
+
async updateInteractionCard(target, providerMessageId, { markup } = {}) {
|
|
553
|
+
await this.#api.editMessageReplyMarkup({
|
|
554
|
+
chatId: target.chatId,
|
|
555
|
+
messageId: Number(providerMessageId),
|
|
556
|
+
replyMarkup: markup,
|
|
557
|
+
signal: this.#signal,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Stop the client-side spinner on a pressed button. */
|
|
562
|
+
async answerInteractionCallback(callbackQueryId, notice) {
|
|
563
|
+
await this.#api.answerCallbackQuery({
|
|
564
|
+
callbackQueryId,
|
|
565
|
+
text: notice,
|
|
566
|
+
signal: this.#signal,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
466
570
|
async addReaction(target, emoji, { signal } = {}) {
|
|
467
571
|
const reactionKey = String(emoji ?? '').trim();
|
|
468
572
|
await this.#api.setMessageReaction({
|
|
@@ -1086,7 +1190,8 @@ export class TelegramRuntime {
|
|
|
1086
1190
|
// All updates have arrived together; cursor persistence must not move the
|
|
1087
1191
|
// settings boundary for the later messages in this received batch.
|
|
1088
1192
|
const received = updates.map((update) => {
|
|
1089
|
-
const chatType = update?.message?.chat?.type
|
|
1193
|
+
const chatType = update?.message?.chat?.type
|
|
1194
|
+
?? update?.callback_query?.message?.chat?.type;
|
|
1090
1195
|
return {
|
|
1091
1196
|
update,
|
|
1092
1197
|
contextSnapshot: captureContextEnhancement(this.#contextEnhancement,
|
|
@@ -1096,6 +1201,23 @@ export class TelegramRuntime {
|
|
|
1096
1201
|
});
|
|
1097
1202
|
for (const { update, contextSnapshot } of received) {
|
|
1098
1203
|
if (signal.aborted) return;
|
|
1204
|
+
const callback = normalizeTelegramCallback(update, {
|
|
1205
|
+
botId: this.#config.platformId,
|
|
1206
|
+
});
|
|
1207
|
+
if (callback) {
|
|
1208
|
+
if (typeof this.#bridge.acceptCallback === 'function') {
|
|
1209
|
+
void this.#bridge.acceptCallback(callback, { contextSnapshot }).catch((error) => {
|
|
1210
|
+
if (signal.aborted) return;
|
|
1211
|
+
this.#logger.error?.(
|
|
1212
|
+
`[dsh-im:telegram] bot ${this.#config.botId} callback handling failed:`,
|
|
1213
|
+
error,
|
|
1214
|
+
);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
cursor = update.update_id + 1;
|
|
1218
|
+
await this.#state.setCursor(cursor);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1099
1221
|
const message = normalizeTelegramUpdate(update, {
|
|
1100
1222
|
botId: this.#config.platformId,
|
|
1101
1223
|
username: this.#config.username,
|
|
@@ -928,6 +928,9 @@ export class WecomHarnessBridge {
|
|
|
928
928
|
...body,
|
|
929
929
|
msgtype: 'text',
|
|
930
930
|
text: { content: result.prompt },
|
|
931
|
+
// The submission is exactly the collected text, so a quote on the
|
|
932
|
+
// command itself must not become part of it.
|
|
933
|
+
quote: undefined,
|
|
931
934
|
},
|
|
932
935
|
}, messageId, key, { batchSubmission: result });
|
|
933
936
|
}
|
|
@@ -1366,6 +1369,7 @@ export class WecomHarnessBridge {
|
|
|
1366
1369
|
key,
|
|
1367
1370
|
text,
|
|
1368
1371
|
content,
|
|
1372
|
+
titleText: batchSubmission?.title,
|
|
1369
1373
|
contextEnhanced,
|
|
1370
1374
|
createOptions: { signal: this.#signal },
|
|
1371
1375
|
existsOptions: { signal: this.#signal },
|
|
@@ -17,7 +17,9 @@ function safeIntegrationId(value) {
|
|
|
17
17
|
|
|
18
18
|
function safeRef(value, prefix) {
|
|
19
19
|
const ref = cleanString(value);
|
|
20
|
-
|
|
20
|
+
if (!ref?.startsWith(prefix)) return null;
|
|
21
|
+
const suffix = ref.slice(prefix.length);
|
|
22
|
+
return /^[A-F0-9]{24}$/.test(suffix) ? ref : null;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// A bot identity is stable per (corpId, agentId) pair so re-binding the same
|
|
@@ -4,6 +4,7 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { normalizeWeixinApiBaseUrl } from './weixin-api.mjs';
|
|
6
6
|
import { t } from '../shared/i18n.mjs';
|
|
7
|
+
import { configValidationError, withConfigResource } from '../shared/config-read-error.mjs';
|
|
7
8
|
|
|
8
9
|
const EMPTY_DOCUMENT = Object.freeze({ version: 1, accounts: Object.freeze([]) });
|
|
9
10
|
|
|
@@ -37,20 +38,24 @@ export function maskWeixinAccountId(accountId) {
|
|
|
37
38
|
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
function normalizeAccount(value) {
|
|
41
|
-
if (!value || typeof value !== 'object') return
|
|
41
|
+
function normalizeAccount(value, invalid = () => null) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid('', 'expected-object');
|
|
42
43
|
const accountId = cleanString(value.accountId);
|
|
43
44
|
const ownerUserId = cleanString(value.ownerUserId);
|
|
44
45
|
const botId = safeBotId(value.botId);
|
|
45
46
|
const tokenRef = safeTokenRef(value.tokenRef);
|
|
46
|
-
if (!accountId
|
|
47
|
+
if (!accountId) return invalid('.accountId', 'invalid-string');
|
|
48
|
+
if (!ownerUserId) return invalid('.ownerUserId', 'invalid-string');
|
|
49
|
+
if (!botId) return invalid('.botId', 'invalid-identifier');
|
|
50
|
+
if (!tokenRef) return invalid('.tokenRef', 'invalid-identifier');
|
|
47
51
|
const derived = deriveWeixinBotIdentity(accountId);
|
|
48
|
-
if (derived.botId !== botId
|
|
52
|
+
if (derived.botId !== botId) return invalid('.botId', 'identity-mismatch');
|
|
53
|
+
if (derived.tokenRef !== tokenRef) return invalid('.tokenRef', 'identity-mismatch');
|
|
49
54
|
let baseUrl;
|
|
50
55
|
try {
|
|
51
56
|
baseUrl = normalizeWeixinApiBaseUrl(value.baseUrl);
|
|
52
|
-
} catch {
|
|
53
|
-
return
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return invalid('.baseUrl', error?.code === 'untrusted-base-url' ? 'untrusted-api-url' : 'invalid-api-url');
|
|
54
59
|
}
|
|
55
60
|
return Object.freeze({
|
|
56
61
|
botId,
|
|
@@ -64,16 +69,21 @@ function normalizeAccount(value) {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
function normalizeDocument(value) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
const invalid = (field, issue) => {
|
|
73
|
+
throw configValidationError('dsh-weixin config contains invalid account data', field, issue);
|
|
74
|
+
};
|
|
75
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid('$', 'expected-object');
|
|
76
|
+
if (value.version !== 1) return invalid('version', 'unsupported-version');
|
|
77
|
+
if (!Array.isArray(value.accounts)) return invalid('accounts', 'expected-array');
|
|
78
|
+
const accounts = value.accounts.map((account, index) => normalizeAccount(account,
|
|
79
|
+
(field, issue) => invalid(`accounts[${index}]${field}`, issue)));
|
|
70
80
|
const ids = new Set();
|
|
71
81
|
const accountIds = new Set();
|
|
72
82
|
const refs = new Set();
|
|
73
|
-
for (const account of accounts) {
|
|
74
|
-
if (ids.has(account.botId)
|
|
75
|
-
|
|
76
|
-
}
|
|
83
|
+
for (const [index, account] of accounts.entries()) {
|
|
84
|
+
if (ids.has(account.botId)) return invalid(`accounts[${index}].botId`, 'duplicate-identity');
|
|
85
|
+
if (accountIds.has(account.accountId)) return invalid(`accounts[${index}].accountId`, 'duplicate-identity');
|
|
86
|
+
if (refs.has(account.tokenRef)) return invalid(`accounts[${index}].tokenRef`, 'duplicate-identity');
|
|
77
87
|
ids.add(account.botId);
|
|
78
88
|
accountIds.add(account.accountId);
|
|
79
89
|
refs.add(account.tokenRef);
|
|
@@ -93,10 +103,9 @@ export class WeixinConfigStore {
|
|
|
93
103
|
async load() {
|
|
94
104
|
try {
|
|
95
105
|
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
96
|
-
if (!normalized) throw new Error('dsh-weixin config contains invalid account data');
|
|
97
106
|
this.#value = normalized;
|
|
98
107
|
} catch (error) {
|
|
99
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
108
|
+
if (error?.code !== 'ENOENT') throw withConfigResource(error, 'account-config');
|
|
100
109
|
this.#value = EMPTY_DOCUMENT;
|
|
101
110
|
}
|
|
102
111
|
return this;
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
// Shared diagnostic copy for the WeChat Host and settings client.
|
|
2
2
|
export default {
|
|
3
|
+
"微信配置格式错误:{file}。请查看诊断详情,修复后重启 DSH。": "The WeChat configuration is invalid: {file}. See the diagnostic details, fix the file, and restart DSH.",
|
|
4
|
+
"配置文件": "Configuration file",
|
|
5
|
+
"配置字段": "Configuration field",
|
|
6
|
+
"校验原因": "Validation issue",
|
|
7
|
+
"应为 JSON 对象。": "Expected a JSON object.",
|
|
8
|
+
"应为 JSON 数组。": "Expected a JSON array.",
|
|
9
|
+
"配置版本缺失或不受当前插件支持。": "The configuration version is missing or unsupported by this plugin.",
|
|
10
|
+
"字段缺失或不是非空字符串。": "The field is missing or is not a non-empty string.",
|
|
11
|
+
"标识符格式不符合要求。": "The identifier has an invalid format.",
|
|
12
|
+
"标识符与 accountId 派生结果不一致。": "The identifier does not match the value derived from accountId.",
|
|
13
|
+
"账号标识重复。": "The account identifier is duplicated.",
|
|
14
|
+
"微信服务地址不是有效 URL。": "The WeChat service address is not a valid URL.",
|
|
15
|
+
"微信服务地址必须使用受信任的 HTTPS 域名和端口。": "The WeChat service address must use a trusted HTTPS domain and port.",
|
|
16
|
+
"工作区路径必须是当前操作系统的绝对路径。": "The workspace path must be absolute on the current operating system.",
|
|
17
|
+
"Agent Preset 标识无效。": "The Agent Preset identifier is invalid.",
|
|
18
|
+
"模型设置须包含有效的 provider、model 和可选 reasoningEffort。": "Model settings require valid provider and model fields, with an optional valid reasoningEffort.",
|
|
19
|
+
"投递目标的标识、结构或路由不符合当前配置版本要求。": "The delivery target identifier, structure or route is invalid for this configuration version.",
|
|
20
|
+
"当前配置版本不允许此字段。": "This field is not allowed in this configuration version.",
|
|
21
|
+
"JSON 语法无效。": "The JSON syntax is invalid.",
|
|
22
|
+
"请检查微信渠道数据目录中的 {file},修复后重启 DSH;“重新读取”不会重新加载配置。": "Check {file} in the WeChat channel data directory, fix it, and restart DSH; refreshing the status does not reload configuration.",
|
|
23
|
+
"字段位置中的序号从 0 开始,按文件中的条目顺序计数,不包含真实账号标识。": "Field positions use zero-based entry order in the file without including actual account identifiers.",
|
|
3
24
|
"插件版本": "Plugin version",
|
|
4
25
|
"DSH 微信管理接口返回了无法识别的响应,请重新读取状态。": "The DSH WeChat management endpoint returned an invalid response. Refresh the status.",
|
|
5
26
|
"无法读取现有登录凭据。请检查 DSH 凭据存储。": "Could not read the saved login credential. Check the DSH credential store.",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import packageInfo from '../../../package.json' with { type: 'json' };
|
|
3
3
|
import { t } from '../shared/i18n.mjs';
|
|
4
|
-
import { normalizeWeixinDiagnosticDetails } from './diagnostic-details.mjs';
|
|
4
|
+
import { CONFIG_ISSUE_LABELS, normalizeWeixinDiagnosticDetails } from './diagnostic-details.mjs';
|
|
5
|
+
import { configReadErrorDetails } from '../shared/config-read-error.mjs';
|
|
5
6
|
|
|
6
7
|
const MESSAGES = Object.freeze({
|
|
7
8
|
'credential-read-failed': '无法读取现有登录凭据。请检查 DSH 凭据存储。',
|
|
@@ -123,6 +124,13 @@ function hintFor(code, details) {
|
|
|
123
124
|
if (details.reason === 'ENOTFOUND' || details.reason === 'EAI_AGAIN') return t('微信服务域名解析失败,请检查运行 DSH 的机器的网络和 DNS 设置后重试。');
|
|
124
125
|
if (['EACCES', 'EPERM', 'EROFS'].includes(details.reason)) return t('请检查 DSH 数据目录和对应文件的读写权限。');
|
|
125
126
|
if (details.reason === 'ENOSPC') return t('磁盘空间不足,请释放空间后重试。');
|
|
127
|
+
if (code.startsWith('weixin-startup-') && details.file) {
|
|
128
|
+
const explanation = details.reason === 'invalid-json' ? t('JSON 语法无效。')
|
|
129
|
+
: details.issue ? t(CONFIG_ISSUE_LABELS[details.issue]) : '';
|
|
130
|
+
return [explanation, t('请检查微信渠道数据目录中的 {file},修复后重启 DSH;“重新读取”不会重新加载配置。', { file: details.file }),
|
|
131
|
+
details.field ? t('字段位置中的序号从 0 开始,按文件中的条目顺序计数,不包含真实账号标识。') : '',
|
|
132
|
+
].filter(Boolean).join(' ');
|
|
133
|
+
}
|
|
126
134
|
if (details.reason === 'invalid-json' && details.resource) return t('本机文件格式无效,请检查对应配置或状态文件;不要清空登录凭据。');
|
|
127
135
|
if (/CERT|TLS|SSL/.test(details.reason ?? '')) return t('请检查运行 DSH 的机器的系统时间、证书和网络设置。');
|
|
128
136
|
if (code === 'stale-token' || code === 'missing-token') return t('请移除失效接入并重新扫码绑定。');
|
|
@@ -143,17 +151,21 @@ export function createWeixinDiagnostics({ logger = console, now = Date.now } = {
|
|
|
143
151
|
const selected = chain.find(error => knownWeixinErrorCode(error.code));
|
|
144
152
|
const code = selected?.code ?? (knownWeixinErrorCode(context.code) ? context.code : 'weixin-operation-failed');
|
|
145
153
|
const staged = chain.map(error => ownedStages.get(error)).find(Boolean) ?? {};
|
|
154
|
+
const configDetails = chain.map(configReadErrorDetails).find(Boolean) ?? {};
|
|
146
155
|
const defaults = CODE_STAGES[code] ?? [];
|
|
147
156
|
const reason = chain.map(error => normalizeWeixinDiagnosticDetails({ reason: error.code }).reason).find(Boolean)
|
|
148
157
|
?? (chain.some(error => error instanceof SyntaxError) ? 'invalid-json' : undefined);
|
|
149
158
|
const numeric = field => chain.map(error => normalizeWeixinDiagnosticDetails({ [field]: field === 'httpStatus' ? error.status : error[field] })[field]).find(value => value !== undefined);
|
|
150
159
|
const details = normalizeWeixinDiagnosticDetails({
|
|
151
|
-
...context,
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
...context, ...configDetails,
|
|
161
|
+
file: { 'account-config': 'config.json', 'workspace-config': 'workspaces.json' }[configDetails.resource],
|
|
162
|
+
stage: staged.stage ?? defaults[0] ?? (code.startsWith('harness-') ? 'harness.check' : context.stage),
|
|
163
|
+
resource: staged.resource ?? defaults[1] ?? configDetails.resource ?? context.resource,
|
|
164
|
+
reason: reason ?? configDetails.reason ?? context.reason, httpStatus: numeric('httpStatus'), providerCode: numeric('providerCode'), pluginVersion: packageInfo.version,
|
|
154
165
|
});
|
|
155
166
|
const botId = /^wx_[a-f0-9]{24}$/.test(context.botId ?? '') ? context.botId : undefined;
|
|
156
|
-
const key = JSON.stringify([botId, details.operation, details.stage, code, details.reason, details.httpStatus, details.providerCode
|
|
167
|
+
const key = JSON.stringify([botId, details.operation, details.stage, code, details.reason, details.httpStatus, details.providerCode,
|
|
168
|
+
details.resource, details.file, details.field, details.issue]);
|
|
157
169
|
const previous = recent.get(key);
|
|
158
170
|
const time = now();
|
|
159
171
|
if (context.automatic && previous && time - previous.time < 60_000) {
|
|
@@ -168,7 +180,10 @@ export function createWeixinDiagnostics({ logger = console, now = Date.now } = {
|
|
|
168
180
|
details.referenceId = `WX-CONN-${randomUUID().replaceAll('-', '').slice(0, 8).toUpperCase()}`;
|
|
169
181
|
details.occurredAt = new Date(time).toISOString();
|
|
170
182
|
details.hint = hintFor(code, details);
|
|
171
|
-
const
|
|
183
|
+
const message = code === 'weixin-startup-config-invalid' && details.file
|
|
184
|
+
? t('微信配置格式错误:{file}。请查看诊断详情,修复后重启 DSH。', { file: details.file })
|
|
185
|
+
: t(MESSAGES[code], { status: details.httpStatus ?? '?' });
|
|
186
|
+
const publicError = { code, message, details };
|
|
172
187
|
const wrapped = new Error(publicError.message, { cause });
|
|
173
188
|
wrapped.code = code;
|
|
174
189
|
wrapped.publicError = publicError;
|
|
@@ -15,9 +15,29 @@ const REASONS = new Set([
|
|
|
15
15
|
'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
|
|
16
16
|
'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'ERR_SSL_WRONG_VERSION_NUMBER',
|
|
17
17
|
'ENOENT', 'EACCES', 'EPERM', 'ENOSPC', 'EROFS', 'ENOTDIR', 'EISDIR', 'EBUSY', 'EIO', 'EXDEV', 'EMFILE', 'ENFILE', 'EEXIST',
|
|
18
|
-
'invalid-json', 'read-only',
|
|
18
|
+
'invalid-json', 'invalid-config', 'read-only',
|
|
19
19
|
]);
|
|
20
20
|
const RESOURCES = new Set(['credential-store', 'account-config', 'account-state', 'workspace-config', 'workspace-directory']);
|
|
21
|
+
const CONFIG_FILES = new Set(['config.json', 'workspaces.json']);
|
|
22
|
+
export const CONFIG_ISSUE_LABELS = Object.freeze({
|
|
23
|
+
'expected-object': '应为 JSON 对象。',
|
|
24
|
+
'expected-array': '应为 JSON 数组。',
|
|
25
|
+
'unsupported-version': '配置版本缺失或不受当前插件支持。',
|
|
26
|
+
'invalid-string': '字段缺失或不是非空字符串。',
|
|
27
|
+
'invalid-identifier': '标识符格式不符合要求。',
|
|
28
|
+
'identity-mismatch': '标识符与 accountId 派生结果不一致。',
|
|
29
|
+
'duplicate-identity': '账号标识重复。',
|
|
30
|
+
'invalid-api-url': '微信服务地址不是有效 URL。',
|
|
31
|
+
'untrusted-api-url': '微信服务地址必须使用受信任的 HTTPS 域名和端口。',
|
|
32
|
+
'invalid-workspace-path': '工作区路径必须是当前操作系统的绝对路径。',
|
|
33
|
+
'invalid-agent-preset': 'Agent Preset 标识无效。',
|
|
34
|
+
'invalid-model-selection': '模型设置须包含有效的 provider、model 和可选 reasoningEffort。',
|
|
35
|
+
'invalid-delivery-target': '投递目标的标识、结构或路由不符合当前配置版本要求。',
|
|
36
|
+
'unexpected-field': '当前配置版本不允许此字段。',
|
|
37
|
+
});
|
|
38
|
+
// Only schema-owned field names and numeric entry positions may leave the Host.
|
|
39
|
+
// Map keys are replaced with zero-based positions to avoid exposing identities.
|
|
40
|
+
const CONFIG_FIELD = /^(?:\$|version|accounts(?:\[\d{1,10}\](?:\.(?:accountId|ownerUserId|botId|tokenRef|baseUrl))?)?|(?:workspaces|agentPresets|models)(?:\[\d{1,10}\]\.(?:key|value))?|deliveryTargets(?:\[\d{1,10}\]\.(?:key|targets(?:\[\d{1,10}\])?))?)$/;
|
|
21
41
|
|
|
22
42
|
export function normalizeWeixinDiagnosticDetails(value) {
|
|
23
43
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
@@ -25,6 +45,9 @@ export function normalizeWeixinDiagnosticDetails(value) {
|
|
|
25
45
|
for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES]]) {
|
|
26
46
|
if (allowed.has(value[field])) result[field] = value[field];
|
|
27
47
|
}
|
|
48
|
+
if (CONFIG_FILES.has(value.file)) result.file = value.file;
|
|
49
|
+
if (typeof value.field === 'string' && CONFIG_FIELD.test(value.field)) result.field = value.field;
|
|
50
|
+
if (typeof value.issue === 'string' && Object.hasOwn(CONFIG_ISSUE_LABELS, value.issue)) result.issue = value.issue;
|
|
28
51
|
if (/^WX-CONN-[A-F0-9]{8}$/.test(value.referenceId ?? '')) result.referenceId = value.referenceId;
|
|
29
52
|
if (typeof value.occurredAt === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value.occurredAt)
|
|
30
53
|
&& Number.isFinite(Date.parse(value.occurredAt))) result.occurredAt = value.occurredAt;
|