@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.
Files changed (51) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +108 -21
  4. package/lib/index.js +276 -276
  5. package/package.json +17 -1
  6. package/plugin-src/client/channel-logos.js +18 -5
  7. package/plugin-src/client/channels/imessage/styles.js +1 -1
  8. package/plugin-src/client/channels/slack/styles.js +1 -1
  9. package/plugin-src/client/channels/weixin/connection-error.js +4 -1
  10. package/plugin-src/client/i18n.js +8 -0
  11. package/plugin-src/client/model-setting.js +4 -2
  12. package/plugin-src/client/session-channel-logos.js +1 -2
  13. package/plugin-src/client/styles.js +3 -2
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/qq/rpc.mjs +2 -1
  16. package/plugin-src/host/modern-harness-api.mjs +91 -3
  17. package/scripts/verify-model-setting.mjs +4 -1
  18. package/scripts/verify-package.mjs +3 -1
  19. package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
  20. package/src/channels/discord/discord-runtime.mjs +4 -1
  21. package/src/channels/feishu/bridge.mjs +9 -1
  22. package/src/channels/feishu/feishu-channel.mjs +1 -1
  23. package/src/channels/qq/qq-bridge.mjs +9 -1
  24. package/src/channels/qq/qq-controller.mjs +11 -5
  25. package/src/channels/qq/state-error.mjs +17 -0
  26. package/src/channels/qq/state-store.mjs +35 -9
  27. package/src/channels/shared/batch-input.mjs +22 -2
  28. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  29. package/src/channels/shared/config-read-error.mjs +24 -0
  30. package/src/channels/shared/harness-client.mjs +13 -12
  31. package/src/channels/shared/harness-question.mjs +10 -2
  32. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  33. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  34. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  35. package/src/channels/shared/semantic/artifact.mjs +1 -1
  36. package/src/channels/shared/text-harness-bridge.mjs +198 -3
  37. package/src/channels/shared/token-config-store.mjs +23 -8
  38. package/src/channels/shared/workspace-session.mjs +7 -1
  39. package/src/channels/slack/slack-runtime.mjs +3 -1
  40. package/src/channels/telegram/telegram-api.mjs +62 -2
  41. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  42. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  43. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  44. package/src/channels/wecom/wecom-bridge.mjs +4 -0
  45. package/src/channels/wecom-app/config-store.mjs +3 -1
  46. package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
  47. package/src/channels/weixin/config-store.mjs +24 -15
  48. package/src/channels/weixin/connection-error.en.mjs +21 -0
  49. package/src/channels/weixin/connection-error.mjs +21 -6
  50. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  51. 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
- return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
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
- return ref && new RegExp('^' + prefix + '[A-F0-9]{24}$').test(ref) ? ref : null;
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
@@ -605,6 +605,7 @@ export class WecomAppBridge {
605
605
  key,
606
606
  text,
607
607
  content,
608
+ titleText: batchSubmission?.title,
608
609
  contextEnhanced,
609
610
  createOptions: { signal: this.#signal },
610
611
  existsOptions: { signal: this.#signal },
@@ -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 null;
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 || !ownerUserId || !botId || !tokenRef) return null;
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 || derived.tokenRef !== tokenRef) return null;
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 null;
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
- if (!value || value.version !== 1 || !Array.isArray(value.accounts)) return null;
68
- const accounts = value.accounts.map(normalizeAccount);
69
- if (accounts.some((account) => account === null)) return null;
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) || accountIds.has(account.accountId) || refs.has(account.tokenRef)) {
75
- return null;
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, stage: staged.stage ?? defaults[0] ?? (code.startsWith('harness-') ? 'harness.check' : context.stage),
152
- resource: staged.resource ?? defaults[1] ?? context.resource,
153
- reason: reason ?? context.reason, httpStatus: numeric('httpStatus'), providerCode: numeric('providerCode'), pluginVersion: packageInfo.version,
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 publicError = { code, message: t(MESSAGES[code], { status: details.httpStatus ?? '?' }), details };
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;
@@ -825,6 +825,7 @@ export class WeixinHarnessBridge {
825
825
  key,
826
826
  text,
827
827
  content,
828
+ titleText: batchSubmission?.title,
828
829
  contextEnhanced,
829
830
  createOptions: { signal: this.#signal },
830
831
  existsOptions: { signal: this.#signal },