@xmanrui/dsh-im 4.20.2 → 4.21.1

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 (61) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +116 -21
  4. package/lib/index.js +288 -287
  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/index.mjs +7 -0
  17. package/plugin-src/host/injected-context.mjs +104 -0
  18. package/plugin-src/host/modern-harness-api.mjs +91 -3
  19. package/scripts/verify-model-setting.mjs +4 -1
  20. package/scripts/verify-package.mjs +3 -1
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
  22. package/src/channels/discord/discord-runtime.mjs +4 -1
  23. package/src/channels/feishu/bridge.mjs +44 -3
  24. package/src/channels/feishu/feishu-channel.mjs +1 -1
  25. package/src/channels/qq/qq-bridge.mjs +36 -3
  26. package/src/channels/qq/qq-controller.mjs +11 -5
  27. package/src/channels/qq/state-error.mjs +17 -0
  28. package/src/channels/qq/state-store.mjs +35 -9
  29. package/src/channels/shared/batch-input.mjs +22 -2
  30. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  31. package/src/channels/shared/config-read-error.mjs +24 -0
  32. package/src/channels/shared/context-enhancement.mjs +40 -3
  33. package/src/channels/shared/control-command.mjs +8 -1
  34. package/src/channels/shared/harness-client.mjs +20 -12
  35. package/src/channels/shared/harness-question.mjs +10 -2
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  37. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  38. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  39. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  40. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  41. package/src/channels/shared/im-source-guidance.mjs +65 -0
  42. package/src/channels/shared/injected-context.mjs +362 -0
  43. package/src/channels/shared/semantic/artifact.mjs +4 -4
  44. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  45. package/src/channels/shared/text-harness-bridge.mjs +221 -5
  46. package/src/channels/shared/token-config-store.mjs +23 -8
  47. package/src/channels/shared/workspace-session.mjs +16 -1
  48. package/src/channels/slack/slack-runtime.mjs +3 -1
  49. package/src/channels/telegram/telegram-api.mjs +62 -2
  50. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  51. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  52. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  53. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  54. package/src/channels/wecom-app/config-store.mjs +3 -1
  55. package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
  56. package/src/channels/weixin/config-store.mjs +24 -15
  57. package/src/channels/weixin/connection-error.en.mjs +21 -0
  58. package/src/channels/weixin/connection-error.mjs +21 -6
  59. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  60. package/src/channels/weixin/weixin-api.mjs +52 -13
  61. package/src/channels/weixin/weixin-bridge.mjs +14 -1
@@ -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,
@@ -33,7 +33,11 @@ import {
33
33
  } from '../shared/preset-command.mjs';
34
34
  import { runWorkspaceCommand, workspacePathSnapshot } from '../shared/workspace-command.mjs';
35
35
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
36
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
36
+ import {
37
+ captureContextEnhancement,
38
+ captureContextEnhancementSource,
39
+ enhanceContextContent,
40
+ } from '../shared/context-enhancement.mjs';
37
41
  import {
38
42
  hasInboundImages,
39
43
  ImagePromptError,
@@ -928,6 +932,9 @@ export class WecomHarnessBridge {
928
932
  ...body,
929
933
  msgtype: 'text',
930
934
  text: { content: result.prompt },
935
+ // The submission is exactly the collected text, so a quote on the
936
+ // command itself must not become part of it.
937
+ quote: undefined,
931
938
  },
932
939
  }, messageId, key, { batchSubmission: result });
933
940
  }
@@ -1163,6 +1170,11 @@ export class WecomHarnessBridge {
1163
1170
  || this.#approvals.hasPending(key),
1164
1171
  control: { owner: this, key },
1165
1172
  deferredDelivery: this.#deferred,
1173
+ enhancement: captureContextEnhancementSource(
1174
+ this.#contextEnhancement,
1175
+ bodyOf(frame).chattype === 'single' ? 'direct' : 'group',
1176
+ () => ({ channel: 'wecom', senderId: bodyOf(frame).from?.userid, chatId }),
1177
+ ),
1166
1178
  });
1167
1179
  if (result?.stopped) {
1168
1180
  await Promise.allSettled([
@@ -1366,6 +1378,8 @@ export class WecomHarnessBridge {
1366
1378
  key,
1367
1379
  text,
1368
1380
  content,
1381
+ titleText: batchSubmission?.title,
1382
+ sourceGuidance: snapshot?.config?.guidance,
1369
1383
  contextEnhanced,
1370
1384
  createOptions: { signal: this.#signal },
1371
1385
  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
@@ -26,7 +26,11 @@ import {
26
26
  } from '../shared/preset-command.mjs';
27
27
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
28
28
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
29
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
29
+ import {
30
+ captureContextEnhancement,
31
+ captureContextEnhancementSource,
32
+ enhanceContextContent,
33
+ } from '../shared/context-enhancement.mjs';
30
34
  import {
31
35
  DEFAULT_IMAGE_PROMPT,
32
36
  hasInboundImages,
@@ -498,6 +502,11 @@ export class WecomAppBridge {
498
502
  || this.#approvals.hasPending(key),
499
503
  control: { owner: this, key },
500
504
  deferredDelivery: this.#deferred,
505
+ enhancement: captureContextEnhancementSource(
506
+ this.#contextEnhancement,
507
+ 'direct',
508
+ () => ({ channel: 'wecom-app', senderId: sender, chatId: sender }),
509
+ ),
501
510
  });
502
511
  if (result?.stopped) {
503
512
  await Promise.allSettled([
@@ -605,6 +614,8 @@ export class WecomAppBridge {
605
614
  key,
606
615
  text,
607
616
  content,
617
+ titleText: batchSubmission?.title,
618
+ sourceGuidance: snapshot?.config?.guidance,
608
619
  contextEnhanced,
609
620
  createOptions: { signal: this.#signal },
610
621
  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;
@@ -21,6 +21,8 @@ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
21
21
  const DEFAULT_TIMEOUT_MS = 15_000;
22
22
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
23
23
  const WEIXIN_CDN_UPLOAD_RETRIES = 3;
24
+ const WEIXIN_CDN_UPLOAD_IDLE_TIMEOUT_MS = 60_000;
25
+ const WEIXIN_CDN_UPLOAD_CHUNK_BYTES = 64 * 1024;
24
26
  const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
25
27
  const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
26
28
  const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
@@ -79,6 +81,9 @@ function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' }
79
81
  || /(?:rate.?limit|too.?many)/i.test(providerText)) {
80
82
  code = 'artifact-rate-limited';
81
83
  message = 'Weixin rate-limited file delivery.';
84
+ } else if (cause?.code === 'upload-timeout') {
85
+ code = 'artifact-upload-timeout';
86
+ message = 'Weixin file upload stalled; the file message was not sent.';
82
87
  } else if (fallback === 'artifact-provider-rejected') {
83
88
  message = 'Weixin rejected the file message.';
84
89
  }
@@ -339,25 +344,54 @@ function weixinCdnUploadUrl(response, fileKey) {
339
344
  return trustedWeixinCdnUploadUrl(url);
340
345
  }
341
346
 
342
- function encryptWeixinUpload(bytes, key) {
347
+ async function* encryptWeixinUpload(bytes, key, { signal, onProgress }) {
343
348
  const cipher = createCipheriv('aes-128-ecb', key, null);
344
- return Buffer.concat([cipher.update(bytes), cipher.final()]);
349
+ // Let fetch backpressure drive encryption, without keeping whole-file
350
+ // ciphertext copies alongside a potentially large artifact buffer.
351
+ for (let offset = 0; offset < bytes.byteLength; offset += WEIXIN_CDN_UPLOAD_CHUNK_BYTES) {
352
+ signal.throwIfAborted();
353
+ const chunk = cipher.update(bytes.subarray(offset, offset + WEIXIN_CDN_UPLOAD_CHUNK_BYTES));
354
+ onProgress();
355
+ if (chunk.byteLength) yield chunk;
356
+ }
357
+ signal.throwIfAborted();
358
+ onProgress();
359
+ yield cipher.final();
345
360
  }
346
361
 
347
- async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
362
+ async function uploadWeixinCdn(fetchImpl, url, bytes, key, { signal } = {}) {
348
363
  let lastError;
349
364
  for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
350
365
  signal?.throwIfAborted();
366
+ const idleController = new AbortController();
367
+ const uploadSignal = signal
368
+ ? AbortSignal.any([signal, idleController.signal])
369
+ : idleController.signal;
370
+ let timer;
371
+ let active = true;
372
+ const onProgress = () => {
373
+ if (!active) return;
374
+ clearTimeout(timer);
375
+ timer = setTimeout(() => idleController.abort(new WeixinApiError(
376
+ 'upload-timeout', '微信文件上传长时间没有进展,已超时。',
377
+ )), WEIXIN_CDN_UPLOAD_IDLE_TIMEOUT_MS);
378
+ };
379
+ const body = encryptWeixinUpload(bytes, key, { signal: uploadSignal, onProgress });
380
+ let response;
381
+ onProgress();
351
382
  try {
352
- const response = await fetchImpl(url, {
383
+ response = await fetchImpl(url, {
353
384
  method: 'POST',
354
- headers: { 'content-type': 'application/octet-stream' },
355
- body: ciphertext,
356
- signal: signal
357
- ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
358
- : AbortSignal.timeout(60_000),
385
+ headers: {
386
+ 'content-type': 'application/octet-stream',
387
+ 'content-length': String(aesEcbPaddedSize(bytes.byteLength)),
388
+ },
389
+ body,
390
+ duplex: 'half',
391
+ signal: uploadSignal,
359
392
  redirect: 'error',
360
393
  });
394
+ uploadSignal.throwIfAborted();
361
395
  if (response.status >= 400 && response.status < 500) {
362
396
  throw new WeixinApiError(
363
397
  'upload-rejected',
@@ -373,16 +407,21 @@ async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
373
407
  );
374
408
  }
375
409
  const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
376
- await response.body?.cancel?.().catch(() => undefined);
377
410
  if (!downloadParam) {
378
411
  throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
379
412
  }
380
413
  return downloadParam;
381
414
  } catch (error) {
382
415
  if (signal?.aborted) throw abortError(signal);
416
+ if (idleController.signal.aborted) error = idleController.signal.reason;
383
417
  if (error instanceof WeixinApiError
384
418
  && (error.code === 'upload-rejected' || error.status < 500)) throw error;
385
419
  lastError = error;
420
+ } finally {
421
+ active = false;
422
+ clearTimeout(timer);
423
+ await body.return();
424
+ await response?.body?.cancel?.().catch(() => undefined);
386
425
  }
387
426
  }
388
427
  if (lastError instanceof WeixinApiError) throw lastError;
@@ -524,10 +563,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
524
563
  ));
525
564
  }
526
565
  const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
527
- const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
566
+ const ciphertextSize = aesEcbPaddedSize(file.bytes.byteLength);
528
567
  let downloadParam;
529
568
  try {
530
- downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
569
+ downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, file.bytes, aesKey, { signal });
531
570
  } catch (error) {
532
571
  if (signal?.aborted) throw abortError(signal);
533
572
  const status = Number(error?.status);
@@ -564,7 +603,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
564
603
  client_id: clientId,
565
604
  message_type: 2,
566
605
  message_state: 2,
567
- item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
606
+ item_list: [createItem({ file, media, ciphertextSize })],
568
607
  ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
569
608
  ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
570
609
  },
@@ -36,7 +36,11 @@ import {
36
36
  } from '../shared/preset-command.mjs';
37
37
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
38
38
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
39
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
39
+ import {
40
+ captureContextEnhancement,
41
+ captureContextEnhancementSource,
42
+ enhanceContextContent,
43
+ } from '../shared/context-enhancement.mjs';
40
44
  import {
41
45
  hasInboundImages,
42
46
  imagePromptDiagnostic,
@@ -262,6 +266,8 @@ function artifactFailureText(fileName, error) {
262
266
  return t('结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。', { name });
263
267
  case 'artifact-too-large':
264
268
  return t('结果文件「{name}」超过当前微信会话可发送的文件大小,未发送。', { name });
269
+ case 'artifact-upload-timeout':
270
+ return t('结果文件「{name}」上传微信时长时间没有进展,已超时,文件尚未发送。请检查网络后重试,或压缩、拆分文件后发送。', { name });
265
271
  case 'artifact-rate-limited':
266
272
  return t('结果文件「{name}」暂时被微信限流,未能发送,请稍后重试。', { name });
267
273
  case 'artifact-provider-rejected':
@@ -707,6 +713,11 @@ export class WeixinHarnessBridge {
707
713
  || this.#approvals.hasPending(key),
708
714
  control: { owner: this, key },
709
715
  deferredDelivery: this.#deferred,
716
+ enhancement: captureContextEnhancementSource(
717
+ this.#contextEnhancement,
718
+ 'direct',
719
+ () => ({ channel: 'weixin', senderId: sender, chatId: sender }),
720
+ ),
710
721
  });
711
722
  if (result?.stopped) {
712
723
  await Promise.allSettled([
@@ -825,6 +836,8 @@ export class WeixinHarnessBridge {
825
836
  key,
826
837
  text,
827
838
  content,
839
+ titleText: batchSubmission?.title,
840
+ sourceGuidance: snapshot?.config?.guidance,
828
841
  contextEnhanced,
829
842
  createOptions: { signal: this.#signal },
830
843
  existsOptions: { signal: this.#signal },