@xmanrui/dsh-im 4.19.2 → 4.20.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 (40) hide show
  1. package/README.en.md +78 -0
  2. package/README.md +78 -0
  3. package/lib/client.js +594 -217
  4. package/lib/index.js +279 -276
  5. package/package.json +9 -1
  6. package/plugin-src/client/channels/weixin/api.js +35 -17
  7. package/plugin-src/client/channels/weixin/connection-error.js +68 -0
  8. package/plugin-src/client/channels/weixin/index.js +36 -12
  9. package/plugin-src/client/i18n.js +2 -0
  10. package/plugin-src/host/channels/shared/startup.mjs +7 -4
  11. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
  12. package/plugin-src/host/channels/weixin/index.mjs +12 -3
  13. package/plugin-src/host/channels/weixin/production.mjs +53 -3
  14. package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
  15. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
  16. package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
  17. package/src/channels/feishu/bridge.mjs +44 -13
  18. package/src/channels/qq/qq-bridge.mjs +12 -4
  19. package/src/channels/qq/qq-menu.mjs +11 -8
  20. package/src/channels/shared/bot-workspace-store.mjs +532 -40
  21. package/src/channels/shared/command-catalog.mjs +5 -0
  22. package/src/channels/shared/compact-command.mjs +14 -4
  23. package/src/channels/shared/control-command.mjs +1 -1
  24. package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
  25. package/src/channels/shared/history-command.mjs +1 -1
  26. package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
  27. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  28. package/src/channels/shared/model-command.mjs +5 -3
  29. package/src/channels/shared/workspace-command.mjs +114 -9
  30. package/src/channels/shared/workspace-session.mjs +55 -5
  31. package/src/channels/telegram/telegram-runtime.mjs +1 -1
  32. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  33. package/src/channels/weixin/connection-error.en.mjs +116 -0
  34. package/src/channels/weixin/connection-error.mjs +204 -0
  35. package/src/channels/weixin/diagnostic-details.mjs +40 -0
  36. package/src/channels/weixin/state-store.mjs +4 -3
  37. package/src/channels/weixin/weixin-api.mjs +20 -8
  38. package/src/channels/weixin/weixin-bridge.mjs +3 -2
  39. package/src/channels/weixin/weixin-controller.mjs +133 -104
  40. package/src/channels/weixin/weixin-runtime.mjs +35 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.19.2",
3
+ "version": "4.20.0",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -56,6 +56,14 @@
56
56
  {
57
57
  "name": "grloper",
58
58
  "url": "https://github.com/grloper"
59
+ },
60
+ {
61
+ "name": "baijian",
62
+ "url": "https://github.com/baijian"
63
+ },
64
+ {
65
+ "name": "lyzhu86",
66
+ "url": "https://github.com/lyzhu86"
59
67
  }
60
68
  ],
61
69
  "license": "MIT",
@@ -1,3 +1,4 @@
1
+ import { normalizeWeixinDiagnosticDetails } from '../../../../src/channels/weixin/diagnostic-details.mjs';
1
2
  import { normalizeBotAlias } from '../../../../src/channels/shared/bot-alias.mjs';
2
3
  import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
3
4
  import { normalizeModelCatalog, normalizeModelSelection, SET_MODEL_ENDPOINT } from '../../model-setting.js';
@@ -57,13 +58,38 @@ function normalizeTestMessage(value) {
57
58
  return { sent: false, code };
58
59
  }
59
60
 
61
+ export function normalizeConnectionError(value, fallbackCode = 'WEIXIN_ERROR', fallbackMessage = '微信操作失败,请稍后重试') {
62
+ const details = normalizeWeixinDiagnosticDetails(value?.details);
63
+ return {
64
+ code: string(value?.code, fallbackCode).slice(0, 100),
65
+ message: string(value?.message, fallbackMessage).slice(0, 500),
66
+ ...(Object.keys(details).length ? { details } : {}),
67
+ };
68
+ }
69
+
70
+ export function managementRequestError(cause, operation) {
71
+ if (cause?.name === 'AbortError') return cause;
72
+ const error = new Error('无法完成微信管理请求,请检查 DSH 连接后重新读取状态。');
73
+ error.code = 'weixin-management-unreachable';
74
+ error.details = normalizeWeixinDiagnosticDetails({ operation, stage: 'management.request', occurredAt: new Date().toISOString() });
75
+ return error;
76
+ }
77
+
78
+ function invalidResponse() {
79
+ const error = new Error('DSH 微信管理接口返回了无法识别的响应,请重新读取状态。');
80
+ error.code = 'weixin-management-invalid-response';
81
+ error.details = { stage: 'management.request', occurredAt: new Date().toISOString() };
82
+ return error;
83
+ }
84
+
60
85
  export function unwrapRpcResult(result) {
61
86
  if (!isRecord(result) || typeof result.ok !== 'boolean') {
62
- throw new Error('微信服务返回了无法识别的响应');
87
+ throw invalidResponse();
63
88
  }
64
89
  if (!result.ok) {
65
- const error = new Error(string(result.error?.message, '微信操作失败'));
66
- error.code = string(result.error?.code, 'WEIXIN_RPC_ERROR');
90
+ if (!isRecord(result.error)) throw invalidResponse();
91
+ const visible = normalizeConnectionError(result.error, 'WEIXIN_RPC_ERROR', '微信操作失败');
92
+ const error = Object.assign(new Error(visible.message), visible);
67
93
  throw error;
68
94
  }
69
95
  return result.value;
@@ -93,7 +119,7 @@ export function safeVerificationUrl(value) {
93
119
 
94
120
  export function normalizeProvisioning(value) {
95
121
  if (!isRecord(value) || !string(value.attemptId)) {
96
- throw new Error('微信扫码服务没有返回有效的绑定任务');
122
+ throw invalidResponse();
97
123
  }
98
124
  const status = PROVISION_STATES.has(value.status) ? value.status : 'failed';
99
125
  const result = {
@@ -110,10 +136,7 @@ export function normalizeProvisioning(value) {
110
136
  if (string(value.botId)) result.botId = string(value.botId);
111
137
  if (value.alreadyConnected === true) result.alreadyConnected = true;
112
138
  if (isRecord(value.error)) {
113
- result.error = {
114
- code: string(value.error.code, 'WEIXIN_PROVISION_FAILED'),
115
- message: string(value.error.message, '微信绑定没有完成'),
116
- };
139
+ result.error = normalizeConnectionError(value.error, 'WEIXIN_PROVISION_FAILED', '微信绑定没有完成');
117
140
  }
118
141
  return result;
119
142
  }
@@ -150,17 +173,14 @@ function normalizeBot(value) {
150
173
  },
151
174
  lastMessageError: normalizeLastMessageError(value.lastMessageError),
152
175
  error: isRecord(value.error)
153
- ? {
154
- code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
155
- message: string(value.error.message, '微信连接未就绪'),
156
- }
176
+ ? normalizeConnectionError(value.error, 'WEIXIN_ACCOUNT_ERROR', '微信连接未就绪')
157
177
  : null,
158
178
  };
159
179
  }
160
180
 
161
181
  export function normalizeSnapshot(value) {
162
182
  if (!isRecord(value) || !Array.isArray(value.bots)) {
163
- throw new Error('微信服务没有返回有效的账号列表');
183
+ throw invalidResponse();
164
184
  }
165
185
  const bots = value.bots.map(normalizeBot).filter(Boolean);
166
186
  return {
@@ -174,16 +194,14 @@ export function normalizeSnapshot(value) {
174
194
  },
175
195
  provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
176
196
  testMessage: normalizeTestMessage(value.testMessage),
197
+ warnings: Array.isArray(value.warnings) ? value.warnings.filter(isRecord).slice(0, 8).map(error => normalizeConnectionError(error)) : [],
177
198
  agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
178
199
  modelCatalog: normalizeModelCatalog(value.modelCatalog),
179
200
  };
180
201
  }
181
202
 
182
203
  export function presentError(error) {
183
- return {
184
- code: string(error?.code, 'WEIXIN_ERROR'),
185
- message: string(error?.message, '微信操作失败,请稍后重试'),
186
- };
204
+ return normalizeConnectionError(error);
187
205
  }
188
206
 
189
207
  export function formatRemaining(milliseconds) {
@@ -0,0 +1,68 @@
1
+ import * as React from 'react';
2
+ import { h, localizeText } from '../../i18n.js';
3
+ import { normalizeConnectionError } from './api.js';
4
+
5
+ const STAGE_LABELS = {
6
+ 'startup.load': '加载微信配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
7
+ 'qr.verify': '提交配对码', 'qr.cancel': '取消绑定', 'credential.read': '读取登录凭据', 'credential.save': '保存登录凭据',
8
+ 'credential.remove': '移除登录凭据', 'account.save': '保存账号配置', 'account.remove': '移除账号配置',
9
+ 'state.load': '读取账号状态', 'state.write': '保存账号状态', 'state.cleanup': '清理账号状态',
10
+ 'workspace.write': '保存工作区设置', 'workspace.cleanup': '清理工作区设置', 'runtime.prepare': '准备消息连接',
11
+ activation: '激活微信账号', 'harness.check': '检查 DSH 宿主', 'connection.start': '启动微信连接',
12
+ 'connection.poll': '同步微信消息', 'connection.stop': '停止微信连接', 'status.read': '读取连接状态',
13
+ rollback: '恢复原账号状态', 'management.request': '访问 DSH 管理接口',
14
+ };
15
+
16
+ export function provisioningErrorTitle(error) {
17
+ const stage = error?.details?.stage;
18
+ if (stage === 'qr.begin' || stage === 'qr.encode') return '无法生成微信二维码';
19
+ if (stage === 'qr.poll') return '查询微信扫码状态失败';
20
+ if (stage === 'management.request') return '无法完成微信管理请求';
21
+ return '微信没有绑定完成';
22
+ }
23
+
24
+ export function formatWeixinDiagnostic(value) {
25
+ const error = normalizeConnectionError(value);
26
+ const details = error.details ?? {};
27
+ return [
28
+ localizeText(error.message), details.hint ? localizeText(details.hint) : null,
29
+ localizeText('错误码') + ': ' + error.code,
30
+ ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
+ .filter(field => details[field] !== undefined)
32
+ .map(field => `${field}: ${details[field]}`),
33
+ ].filter(Boolean).join('\n');
34
+ }
35
+
36
+ export function WeixinConnectionError({ error: value, warning = false }) {
37
+ const error = normalizeConnectionError(value);
38
+ const details = error.details ?? {};
39
+ const [copyState, setCopyState] = React.useState(null);
40
+ React.useEffect(() => setCopyState(null), [details.referenceId, error.code, error.message]);
41
+ const copy = async () => {
42
+ try {
43
+ if (typeof globalThis.navigator?.clipboard?.writeText !== 'function') throw new Error('clipboard unavailable');
44
+ await globalThis.navigator.clipboard.writeText(formatWeixinDiagnostic(error));
45
+ setCopyState('copied');
46
+ } catch { setCopyState('manual'); }
47
+ };
48
+ const fields = [
49
+ ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
50
+ ['底层原因', details.reason], ['HTTP 状态', details.httpStatus], ['微信返回码', details.providerCode],
51
+ ['参考号', details.referenceId], ['发生时间', details.occurredAt], ['插件版本', details.pluginVersion],
52
+ ].filter(([, text]) => text !== undefined && text !== '');
53
+ return h('div', { className: 'dxw-summary dim-cardSummary', 'data-weixin-diagnostic': true, role: warning ? 'status' : undefined },
54
+ h('p', null, error.message),
55
+ details.hint ? h('p', null, details.hint) : null,
56
+ h('details', { style: { marginTop: 8 } },
57
+ h('summary', { style: { cursor: 'pointer' } }, '诊断详情'),
58
+ h('dl', { style: { display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '4px 12px', margin: '10px 0' } },
59
+ ...fields.flatMap(([label, text]) => [h('dt', { key: `${label}-label` }, label),
60
+ React.createElement('dd', { key: label, style: { margin: 0, overflowWrap: 'anywhere' } }, String(text))])),
61
+ !details.referenceId ? h('p', null, '未取得 Host 诊断参考号。') : null,
62
+ h('div', { className: 'dim-viewActions' },
63
+ h('button', { type: 'button', className: 'dxw-button', onClick: copy }, copyState === 'copied' ? '诊断信息已复制' : '复制诊断信息')),
64
+ copyState === 'manual' ? h('div', null,
65
+ h('p', null, '无法访问剪贴板,请选择并复制以下诊断信息。'),
66
+ React.createElement('textarea', { readOnly: true, value: formatWeixinDiagnostic(error), rows: 7,
67
+ 'aria-label': localizeText('诊断信息'), style: { width: '100%', boxSizing: 'border-box', marginTop: 8 } })) : null));
68
+ }
@@ -1,3 +1,4 @@
1
+ import { WeixinConnectionError, provisioningErrorTitle } from './connection-error.js';
1
2
  import { BotName } from '../../bot-alias.js';
2
3
  import * as React from 'react';
3
4
 
@@ -7,6 +8,7 @@ import { CollapsibleAccountSection } from '../shared/collapsible-account.js';
7
8
  import { h } from '../../i18n.js';
8
9
  import {
9
10
  WEIXIN_ENDPOINTS,
11
+ managementRequestError,
10
12
  formatRemaining,
11
13
  normalizeProvisioning,
12
14
  normalizeSnapshot,
@@ -189,9 +191,8 @@ function ProvisionError({ provision, busy, onRetry, onClose }) {
189
191
  const error = provision.error ?? { code: 'WEIXIN_PROVISION_FAILED', message: '微信绑定没有完成' };
190
192
  return h('div', { className: 'dxw-card dim-surfaceCard' },
191
193
  h('div', { className: 'dxw-error dim-inlineError', role: 'alert' },
192
- h('h3', null, provision.status === 'expired' ? '二维码已过期' : '微信没有绑定完成'),
193
- h('p', null, error.message),
194
- h('span', { className: 'dxw-errorCode' }, error.code),
194
+ h('h3', null, provision.status === 'expired' ? '二维码已过期' : provisioningErrorTitle(error)),
195
+ h(WeixinConnectionError, { error }),
195
196
  h('div', { className: 'dxw-actions dim-viewActions' },
196
197
  h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
197
198
  h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
@@ -283,7 +284,8 @@ export function AccountCard({
283
284
  h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
284
285
  busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
285
286
  h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
286
- summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
287
+ account.error ? h(WeixinConnectionError, { error: account.error })
288
+ : summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
287
289
  account.lastMessageError ? h(LastMessageErrorSummary, {
288
290
  className: 'dxw-summary',
289
291
  error: account.lastMessageError,
@@ -292,7 +294,7 @@ export function AccountCard({
292
294
  className: 'dxw-summary dim-cardFeedback',
293
295
  role: 'status',
294
296
  'aria-live': 'polite',
295
- }, feedback) : null)),
297
+ }, typeof feedback === 'string' ? feedback : h(WeixinConnectionError, { error: feedback })) : null)),
296
298
  ),
297
299
  ),
298
300
  removing ? h('div', { className: 'dxw-confirm dim-confirm', role: 'alertdialog' },
@@ -358,6 +360,7 @@ export function WeixinSettingsTab({ rpcCall }) {
358
360
  const [feedbackByBot, setFeedbackByBot] = React.useState({});
359
361
  const [removeTarget, setRemoveTarget] = React.useState(null);
360
362
  const [notice, setNotice] = React.useState('');
363
+ const [operationWarnings, setOperationWarnings] = React.useState([]);
361
364
  const [now, setNow] = React.useState(() => Date.now());
362
365
  const addButtonRef = React.useRef(null);
363
366
  const mountedRef = React.useRef(true);
@@ -376,7 +379,13 @@ export function WeixinSettingsTab({ rpcCall }) {
376
379
  }, 'announcement');
377
380
  }, [scheduleAnimationFrame]);
378
381
  const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
379
- return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
382
+ let result;
383
+ try { result = await rpcCall(endpoint, payload, signal); }
384
+ catch (error) {
385
+ if (signal?.aborted) throw error;
386
+ throw managementRequestError(error, endpoint);
387
+ }
388
+ return unwrapRpcResult(result);
380
389
  }, [rpcCall]);
381
390
  const loadStatus = React.useCallback(async ({
382
391
  signal,
@@ -606,12 +615,12 @@ export function WeixinSettingsTab({ rpcCall }) {
606
615
  setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
607
616
  }
608
617
  announce(feedback);
609
- } catch {
610
- const feedback = '连接检查失败,请稍后重试。';
618
+ } catch (error) {
619
+ const feedback = presentError(error);
611
620
  if (mountedRef.current) {
612
621
  setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
613
622
  }
614
- announce(feedback);
623
+ announce(feedback.message);
615
624
  } finally {
616
625
  const shouldRefresh = workspaceFence.endMutation();
617
626
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -622,6 +631,7 @@ export function WeixinSettingsTab({ rpcCall }) {
622
631
  const saveWorkspace = React.useCallback(async (account, workspace) => {
623
632
  const workspaceVersion = workspaceFence.beginMutation();
624
633
  setBotBusy(account.botId, 'workspace');
634
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
625
635
  try {
626
636
  const snapshot = normalizeSnapshot(await invoke(
627
637
  WEIXIN_ENDPOINTS.setWorkspace,
@@ -635,6 +645,9 @@ export function WeixinSettingsTab({ rpcCall }) {
635
645
  modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
636
646
  });
637
647
  }
648
+ } catch (error) {
649
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: presentError(error) }));
650
+ throw error;
638
651
  } finally {
639
652
  const shouldRefresh = workspaceFence.endMutation();
640
653
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -645,6 +658,7 @@ export function WeixinSettingsTab({ rpcCall }) {
645
658
  const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
646
659
  const snapshotVersion = workspaceFence.beginMutation();
647
660
  setBotBusy(account.botId, operation);
661
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
648
662
  try {
649
663
  const snapshot = normalizeSnapshot(await invoke(
650
664
  endpoint,
@@ -658,6 +672,9 @@ export function WeixinSettingsTab({ rpcCall }) {
658
672
  modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
659
673
  });
660
674
  }
675
+ } catch (error) {
676
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: presentError(error) }));
677
+ throw error;
661
678
  } finally {
662
679
  const shouldRefresh = workspaceFence.endMutation();
663
680
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -668,6 +685,8 @@ export function WeixinSettingsTab({ rpcCall }) {
668
685
  const remove = React.useCallback(async (account) => {
669
686
  const snapshotVersion = workspaceFence.beginMutation();
670
687
  setBotBusy(account.botId, 'delete');
688
+ setOperationWarnings([]);
689
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
671
690
  try {
672
691
  const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
673
692
  botId: account.botId,
@@ -680,10 +699,13 @@ export function WeixinSettingsTab({ rpcCall }) {
680
699
  modelCatalog: snapshot.modelCatalog ?? current.modelCatalog,
681
700
  }));
682
701
  }
702
+ setOperationWarnings(snapshot.warnings);
683
703
  setRemoveTarget(null);
684
704
  announce('微信账号及本机凭据已移除。');
685
705
  } catch (error) {
686
- announce(`移除失败:${presentError(error).message}`);
706
+ const failure = presentError(error);
707
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: failure }));
708
+ announce(failure.message);
687
709
  } finally {
688
710
  const shouldRefresh = workspaceFence.endMutation();
689
711
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -731,8 +753,10 @@ export function WeixinSettingsTab({ rpcCall }) {
731
753
  addButtonRef,
732
754
  }),
733
755
  h('div', { className: 'dxw-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
756
+ ...operationWarnings.map((error, index) => h(WeixinConnectionError, { key: error.details?.referenceId ?? index, error, warning: true })),
734
757
  model.error && model.phase === 'ready'
735
- ? h('div', { className: 'dxw-statusNotice dim-statusNotice' }, `状态刷新失败:${model.error.message}`)
758
+ ? h('div', { className: 'dxw-statusNotice dim-statusNotice' },
759
+ h('p', null, '状态读取失败,以下是上次读取的状态。'), h(WeixinConnectionError, { error: model.error }))
736
760
  : null,
737
761
  model.phase === 'loading'
738
762
  ? h(LoadingView)
@@ -740,7 +764,7 @@ export function WeixinSettingsTab({ rpcCall }) {
740
764
  ? h('div', { className: 'dxw-card dim-surfaceCard' },
741
765
  h('div', { className: 'dxw-error dim-inlineError' },
742
766
  h('h3', null, '无法读取微信状态'),
743
- h('p', null, model.error?.message ?? '请稍后重试'),
767
+ h(WeixinConnectionError, { error: model.error }),
744
768
  h(Button, { onClick: () => void loadStatus() }, '重新读取')))
745
769
  : h(React.Fragment, null,
746
770
  provisionView,
@@ -1,8 +1,10 @@
1
+ import weixinDiagnostics from '../../src/channels/weixin/connection-error.en.mjs';
1
2
  import * as React from 'react';
2
3
 
3
4
  export const IM_LOCALE_NAMESPACE = 'dsh-im';
4
5
 
5
6
  const EN = Object.freeze({
7
+ ...weixinDiagnostics,
6
8
  '$locale': 'en',
7
9
  '修改别名': 'Edit alias',
8
10
  '关闭修改别名': 'Close alias editor',
@@ -26,7 +26,7 @@ function followHostLanguage(ctx, channel, controller, logger) {
26
26
 
27
27
  /** Mount the native management RPC before any fallible production initialization. */
28
28
  export async function installProductionChannel(ctx, config, {
29
- channel, rpcChannel, createProduction, createHandler,
29
+ channel, rpcChannel, createProduction, createHandler, reportStartupError,
30
30
  }) {
31
31
  let startupError = publicChannelInitializing(channel);
32
32
  let handler = async () => ({ ok: false, error: startupError });
@@ -56,12 +56,15 @@ export async function installProductionChannel(ctx, config, {
56
56
  followHostLanguage(ctx, channel, production.controller, logger);
57
57
  handler = readyHandler;
58
58
  } catch (error) {
59
- startupError = publicChannelStartupError(channel, error);
60
- logger.error?.(`[dsh-im] failed to activate ${channel}; management RPC remains available`, error);
59
+ startupError = reportStartupError
60
+ ? reportStartupError(error, false)
61
+ : publicChannelStartupError(channel, error);
62
+ if (!reportStartupError) logger.error?.(`[dsh-im] failed to activate ${channel}; management RPC remains available`, error);
61
63
  try {
62
64
  await closeProduction();
63
65
  } catch (cleanupError) {
64
- logger.error?.(`[dsh-im] failed to close partially initialized ${channel} resources`, cleanupError);
66
+ if (reportStartupError) reportStartupError(cleanupError, true);
67
+ else logger.error?.(`[dsh-im] failed to close partially initialized ${channel} resources`, cleanupError);
65
68
  }
66
69
  }
67
70
  return disposeRpc;
@@ -1,3 +1,5 @@
1
+ import { createWeixinDiagnostics } from '../../../../src/channels/weixin/connection-error.mjs';
2
+
1
3
  const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
2
4
 
3
5
  function retryDelays(value) {
@@ -10,6 +12,7 @@ export class ConnectionSupervisor {
10
12
  #controller;
11
13
  #harness;
12
14
  #logger;
15
+ #diagnostics;
13
16
  #retryDelays;
14
17
  #healthyIntervalMs;
15
18
  #setTimeout;
@@ -26,6 +29,7 @@ export class ConnectionSupervisor {
26
29
  controller,
27
30
  harness,
28
31
  logger = console,
32
+ diagnostics,
29
33
  retryDelaysMs,
30
34
  healthyIntervalMs = 15_000,
31
35
  setTimeoutImpl = setTimeout,
@@ -40,6 +44,7 @@ export class ConnectionSupervisor {
40
44
  this.#controller = controller;
41
45
  this.#harness = harness;
42
46
  this.#logger = logger;
47
+ this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
43
48
  this.#retryDelays = retryDelays(retryDelaysMs);
44
49
  this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
45
50
  ? healthyIntervalMs
@@ -116,7 +121,14 @@ export class ConnectionSupervisor {
116
121
  if (this.#closed) return;
117
122
  const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
118
123
  this.#retryIndex += 1;
119
- this.#logger.warn?.(`[dsh-weixin] connection reconciliation failed; retrying in ${delayMs}ms`, error);
124
+ if (typeof this.#controller.reportRestoreFailure === 'function') {
125
+ try { await this.#controller.reportRestoreFailure(error); }
126
+ catch {
127
+ this.#diagnostics.report(error, { operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed', automatic: true });
128
+ }
129
+ } else {
130
+ this.#diagnostics.report(error, { operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed', automatic: true });
131
+ }
120
132
  this.#schedule(delayMs);
121
133
  }
122
134
  }
@@ -1,20 +1,29 @@
1
1
  import { createProductionController } from './production.mjs';
2
2
  import { createWeixinRpcHandler, installWeixinRpc, WEIXIN_RPC_CHANNEL } from './rpc.mjs';
3
3
  import { installProductionChannel } from '../shared/startup.mjs';
4
+ import { publicChannelStartupError } from '../shared/startup-error.mjs';
5
+ import { createWeixinDiagnostics } from '../../../../src/channels/weixin/connection-error.mjs';
4
6
 
5
7
  export const name = 'dsh-weixin-host';
6
8
  export const inject = ['connection', 'credentials', 'typertGateway'];
7
9
 
8
10
  export async function apply(ctx, config = {}) {
11
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-weixin') : (ctx.logger ?? console);
12
+ const diagnostics = createWeixinDiagnostics({ logger });
13
+ const rpcOptions = { ...config.rpcOptions, logger, diagnostics };
9
14
  if (config?.controller) {
10
- return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
15
+ return installWeixinRpc(ctx, config.controller, rpcOptions, config.rpcAuthority);
11
16
  }
12
17
 
13
18
  return installProductionChannel(ctx, config, {
14
19
  channel: 'weixin',
15
20
  rpcChannel: WEIXIN_RPC_CHANNEL,
16
- createProduction: () => createProductionController(ctx, config, config.internals),
17
- createHandler: controller => createWeixinRpcHandler(controller, config.rpcOptions),
21
+ createProduction: () => createProductionController(ctx, config, { ...config.internals, diagnostics }),
22
+ createHandler: controller => createWeixinRpcHandler(controller, rpcOptions),
23
+ reportStartupError: (error, warning) => diagnostics.report(error, {
24
+ operation: 'startup', stage: warning ? 'connection.stop' : 'startup.load', warning,
25
+ code: publicChannelStartupError('weixin', error).code,
26
+ }).publicError,
18
27
  });
19
28
  }
20
29
 
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../../../src/channels/weixin/weixin-api.mjs';
12
12
  import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
13
13
  import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
14
+ import { createWeixinDiagnostics, knownWeixinErrorCode, weixinStageError } from '../../../../src/channels/weixin/connection-error.mjs';
14
15
  import {
15
16
  BotWorkspaceStore,
16
17
  createBotWorkspaceScope,
@@ -44,6 +45,33 @@ function pluginPaths(config) {
44
45
  };
45
46
  }
46
47
 
48
+ // Keep shared workspace lifecycle semantics; expose its fallible persistence only to Weixin.
49
+ function diagnosticWorkspaces(store) {
50
+ const writes = new Set(['ensure', 'setWorkspace', 'setModel', 'setAgentPreset', 'setAlias', 'setContextEnhancement', 'setAccessPolicy', 'flushPendingRemoval']);
51
+ const removals = new Set(['retireAfterConfigCommit', 'finishRemoval']);
52
+ return new Proxy(store, {
53
+ get(target, property) {
54
+ const value = Reflect.get(target, property, target);
55
+ if (typeof value !== 'function') return value;
56
+ if (!writes.has(property) && !removals.has(property)) return value.bind(target);
57
+ return (...args) => {
58
+ const failure = error => {
59
+ throw knownWeixinErrorCode(error?.code) ? error
60
+ : weixinStageError(removals.has(property) ? 'workspace-cleanup-failed' : 'workspace-save-failed', error);
61
+ };
62
+ try {
63
+ const result = value.apply(target, args);
64
+ // flushPendingRemoval must remain synchronous when no cleanup is pending.
65
+ return result?.then ? result.then(value => {
66
+ if (removals.has(property) && value?.error) failure(value.error);
67
+ return value;
68
+ }, failure) : result;
69
+ } catch (error) { return failure(error); }
70
+ };
71
+ },
72
+ });
73
+ }
74
+
47
75
  export async function createProductionController(ctx, config = {}, internals = {}) {
48
76
  if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
49
77
  const connection = harnessConnection(ctx, config);
@@ -58,13 +86,14 @@ export async function createProductionController(ctx, config = {}, internals = {
58
86
  const logger = typeof ctx.logger === 'function'
59
87
  ? ctx.logger('dsh-weixin')
60
88
  : (ctx.logger ?? console);
89
+ const diagnostics = internals.diagnostics ?? createWeixinDiagnostics({ logger });
61
90
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
62
91
  const paths = pluginPaths(config);
63
92
  const configStore = await new ConfigStore(paths.config).load();
64
93
  const defaultWorkspace = resolve(config.workspace ?? process.cwd());
65
94
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
66
- const workspaces = internals.workspaces
67
- ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
95
+ const workspaces = diagnosticWorkspaces(internals.workspaces
96
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
68
97
  const configuredBots = configStore.list();
69
98
  await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
70
99
  await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
@@ -115,6 +144,7 @@ export async function createProductionController(ctx, config = {}, internals = {
115
144
  credentials: ctx.credentials,
116
145
  configStore: observedConfigStore,
117
146
  logger,
147
+ diagnostics,
118
148
  createRuntime: async ({ botId, config: accountConfig, token }) => {
119
149
  const state = await stateFor(botId);
120
150
  await workspaces.ensure(botId, {
@@ -125,6 +155,7 @@ export async function createProductionController(ctx, config = {}, internals = {
125
155
  botId, workspaces, state, agentPresetCatalog,
126
156
  });
127
157
  return new Runtime({
158
+ diagnostics,
128
159
  api,
129
160
  config: accountConfig,
130
161
  token,
@@ -158,16 +189,35 @@ export async function createProductionController(ctx, config = {}, internals = {
158
189
  }
159
190
  },
160
191
  });
161
- const controller = createWorkspaceAwareController(coreController, {
192
+ const workspaceController = createWorkspaceAwareController(coreController, {
162
193
  workspaces,
163
194
  stateFor,
164
195
  agentPresetCatalog,
165
196
  modelCatalog,
166
197
  });
198
+ const controller = new Proxy(workspaceController, {
199
+ get(target, property) {
200
+ if (property === 'deleteBot') return async (...args) => {
201
+ const existed = Boolean(configStore.get(args[0]));
202
+ try { return await target.deleteBot(...args); }
203
+ catch (error) {
204
+ if (!existed || configStore.get(args[0])) throw error;
205
+ // The shared workspace wrapper can fail cleaning up after the account commit.
206
+ const warning = diagnostics.report(weixinStageError('workspace-cleanup-failed', error), {
207
+ operation: 'bot.delete', botId: args[0], warning: true,
208
+ }).publicError;
209
+ try { return { ...await target.status(), warnings: [warning] }; }
210
+ catch (readError) { throw weixinStageError('status-read-failed', readError, 'status.read'); }
211
+ }
212
+ };
213
+ return Reflect.get(target, property);
214
+ },
215
+ });
167
216
  const supervisor = createSupervisor({
168
217
  controller,
169
218
  harness,
170
219
  logger,
220
+ diagnostics,
171
221
  retryDelaysMs: config.retryDelaysMs,
172
222
  healthyIntervalMs: config.healthyIntervalMs,
173
223
  }).start();