@xmanrui/dsh-im 0.1.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,577 @@
1
+ import * as React from 'react';
2
+
3
+ import { WeixinLogoGlyph } from '../../channel-logos.js';
4
+ import { QrActionIcon } from '../../credential-binding.js';
5
+ import {
6
+ WEIXIN_ENDPOINTS,
7
+ WEIXIN_RPC_CHANNEL,
8
+ formatRemaining,
9
+ normalizeProvisioning,
10
+ normalizeSnapshot,
11
+ presentError,
12
+ safeQrSource,
13
+ safeVerificationUrl,
14
+ unwrapRpcResult,
15
+ } from './api.js';
16
+ import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
17
+ import { installWeixinStyles } from './styles.js';
18
+
19
+ const h = React.createElement;
20
+
21
+ export const name = 'weixin-settings';
22
+ export const inject = ['slots', 'connection'];
23
+
24
+ const Button = React.forwardRef(function Button(
25
+ { children, kind = 'secondary', className = '', ...props },
26
+ ref,
27
+ ) {
28
+ return h('button', {
29
+ ...props,
30
+ ref,
31
+ type: 'button',
32
+ className: `dxw-button ${className}`.trim(),
33
+ 'data-kind': kind,
34
+ }, children);
35
+ });
36
+
37
+ function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
38
+ return h('div', { className: 'dxw-heading' },
39
+ h('div', { className: 'dxw-tools' },
40
+ h(Button, {
41
+ kind: 'primary',
42
+ className: 'dim-scanButton',
43
+ onClick: onAdd,
44
+ disabled: adding || busy,
45
+ ref: addButtonRef,
46
+ 'aria-label': '扫码接入微信机器人',
47
+ }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
48
+ totals.configured > 0
49
+ ? h('div', { className: 'dxw-badge dim-onlineBadge' },
50
+ h('span', null, `${totals.connected} / ${totals.configured} 在线`))
51
+ : null,
52
+ ),
53
+ );
54
+ }
55
+
56
+ function LoadingView() {
57
+ return h('div', { className: 'dxw-card dxw-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
58
+ h('div', { className: 'dxw-spinner dim-spinner' }),
59
+ h('span', null, '正在读取微信连接状态…'));
60
+ }
61
+
62
+ function EmptyView({ onStart, busy }) {
63
+ return h('div', { className: 'dxw-card dim-surfaceCard' },
64
+ h('div', { className: 'dxw-cardBody dxw-empty dim-surfaceBody dim-emptyView' },
65
+ h('div', { className: 'dim-emptyCopy' },
66
+ h('div', { className: 'dxw-stateLabel dim-stateLabel' },
67
+ h('span', { className: 'dxw-dot dim-stateDot' }), h('span', null, '尚未绑定微信')),
68
+ h('h3', null, '扫一次码,就能在微信里使用 Harness'),
69
+ h('p', null, '二维码由腾讯微信 iLink 服务签发。用手机微信扫描并确认后,账号凭据会直接写入 Harness Host,浏览器不会收到 bot_token。'),
70
+ h('div', { className: 'dxw-actions dim-viewActions' },
71
+ h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
72
+ busy ? '正在生成二维码…' : '生成微信二维码')),
73
+ ),
74
+ h('div', { className: 'dxw-logo dim-emptyBrand', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 64 })),
75
+ ));
76
+ }
77
+
78
+ function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
79
+ const [imageFailed, setImageFailed] = React.useState(false);
80
+ const source = safeQrSource(provision.qrCodeDataUrl);
81
+ const href = safeVerificationUrl(provision.verificationUrl);
82
+ const remaining = Math.max(0, provision.expiresAt - now);
83
+ const expired = remaining === 0 || provision.status === 'expired';
84
+ const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
85
+ const progress = Math.round(Math.min(1, remaining / duration) * 100);
86
+ React.useEffect(() => setImageFailed(false), [source]);
87
+
88
+ return h('div', { className: 'dxw-card dim-surfaceCard' },
89
+ h('div', { className: 'dxw-cardBody dxw-qrLayout dim-surfaceBody dim-qrLayout' },
90
+ h('div', { className: 'dxw-qrColumn dim-qrColumn' },
91
+ h('div', { className: 'dxw-qrFrame dim-qrFrame' },
92
+ source && !imageFailed
93
+ ? h('img', {
94
+ src: source,
95
+ alt: '用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码',
96
+ onError: () => setImageFailed(true),
97
+ })
98
+ : h('div', { className: 'dxw-qrFallback dim-qrFallback' }, '二维码图片未就绪,请使用备用链接。'),
99
+ expired ? h('div', { className: 'dxw-expired dim-qrExpired' }, '二维码已过期\n请重新生成') : null,
100
+ ),
101
+ h('div', { className: 'dxw-countdown dim-countdown' },
102
+ h('div', { className: 'dim-countdownTop' }, h('span', null, '二维码有效时间'), h('strong', null, formatRemaining(remaining))),
103
+ h('div', { className: 'dxw-progress dim-progress', 'aria-hidden': 'true' },
104
+ h('span', { style: { '--dxw-progress': `${progress}%` } })),
105
+ )),
106
+ h('div', { className: 'dxw-qrCopy dim-qrCopy' },
107
+ h('div', { className: 'dxw-stateLabel dim-stateLabel' },
108
+ h('span', { className: 'dxw-dot dim-stateDot', 'data-tone': provision.status === 'scanned' ? 'success' : 'warning' }),
109
+ h('span', null, provision.status === 'scanned' ? '已扫码,请在手机上确认' : '等待微信扫码')),
110
+ h('h3', null, expired ? '二维码已失效' : '使用手机微信扫描二维码'),
111
+ h('p', null, '请在手机上核对并确认授权。部分账号会额外显示一个配对数字,页面会在需要时提示输入。'),
112
+ h('ol', { className: 'dxw-steps dim-steps' },
113
+ h('li', null, '打开手机微信并扫描左侧二维码'),
114
+ h('li', null, '在微信中确认连接该机器人'),
115
+ h('li', null, '保持本页打开,等待消息长轮询变为在线')),
116
+ h('div', { className: 'dxw-actions dim-viewActions' },
117
+ expired
118
+ ? h(Button, { kind: 'primary', onClick: onRefresh, disabled: busy }, '重新生成二维码')
119
+ : null,
120
+ href ? h('a', {
121
+ className: 'dxw-button', href, target: '_blank', rel: 'noopener noreferrer',
122
+ }, '打开备用链接') : null,
123
+ !expired ? h(Button, { onClick: onRefresh, disabled: busy }, '换一个二维码') : null,
124
+ h(Button, { onClick: onCancel, disabled: busy }, '取消')),
125
+ ),
126
+ ));
127
+ }
128
+
129
+ function VerificationPanel({ provision, busy, onSubmit, onCancel }) {
130
+ const [code, setCode] = React.useState('');
131
+ const valid = /^\d{4,8}$/.test(code);
132
+ React.useEffect(() => setCode(''), [provision.attemptId]);
133
+ return h('div', { className: 'dxw-card dim-surfaceCard' },
134
+ h('form', {
135
+ className: 'dxw-verify dim-specialView',
136
+ onSubmit: (event) => {
137
+ event.preventDefault();
138
+ if (valid && !busy) onSubmit(code);
139
+ },
140
+ },
141
+ h('div', { className: 'dxw-stateLabel' },
142
+ h('span', { className: 'dxw-dot', 'data-tone': 'warning' }), h('span', null, '需要配对码')),
143
+ h('h3', null, '输入手机微信显示的数字'),
144
+ h('p', null, '这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。'),
145
+ h('div', { className: 'dxw-codeRow' },
146
+ h('input', {
147
+ className: 'dxw-input',
148
+ value: code,
149
+ inputMode: 'numeric',
150
+ autoComplete: 'one-time-code',
151
+ maxLength: 8,
152
+ 'aria-label': '微信配对码',
153
+ onChange: (event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 8)),
154
+ autoFocus: true,
155
+ }),
156
+ h('button', {
157
+ type: 'submit',
158
+ className: 'dxw-button',
159
+ 'data-kind': 'primary',
160
+ disabled: !valid || busy,
161
+ }, busy ? '正在验证…' : '继续连接')),
162
+ h(Button, { onClick: onCancel, disabled: busy }, '取消绑定')));
163
+ }
164
+
165
+ function ProgressPanel({ scanned, onCancel, busy }) {
166
+ return h('div', { className: 'dxw-card dxw-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
167
+ h('div', { className: 'dxw-spinner dim-spinner' }),
168
+ h('h3', null, scanned ? '微信已确认,正在启动消息连接' : '正在准备微信二维码'),
169
+ h('p', null, scanned ? '正在保存凭据并验证 Harness 与微信长轮询。' : '正在联系腾讯微信 iLink 服务。'),
170
+ onCancel ? h('div', { className: 'dxw-actions dim-viewActions', style: { justifyContent: 'center', marginTop: 14 } },
171
+ h(Button, { onClick: onCancel, disabled: busy }, '取消')) : null);
172
+ }
173
+
174
+ function ProvisionError({ provision, busy, onRetry, onClose }) {
175
+ const error = provision.error ?? { code: 'WEIXIN_PROVISION_FAILED', message: '微信绑定没有完成' };
176
+ return h('div', { className: 'dxw-card dim-surfaceCard' },
177
+ h('div', { className: 'dxw-error dim-inlineError', role: 'alert' },
178
+ h('h3', null, provision.status === 'expired' ? '二维码已过期' : '微信没有绑定完成'),
179
+ h('p', null, error.message),
180
+ h('span', { className: 'dxw-errorCode' }, error.code),
181
+ h('div', { className: 'dxw-actions dim-viewActions' },
182
+ h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
183
+ h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
184
+ }
185
+
186
+ function checkedTime(timestamp) {
187
+ if (!timestamp) return '尚未检查';
188
+ try {
189
+ return new Intl.DateTimeFormat('zh-CN', {
190
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
191
+ }).format(new Date(timestamp));
192
+ } catch {
193
+ return '刚刚';
194
+ }
195
+ }
196
+
197
+ export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
198
+ const state = busy === 'reconnect' ? 'connecting' : account.state;
199
+ const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
200
+ const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
201
+ return h('article', { className: 'dxw-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId },
202
+ h('div', { className: 'dxw-cardBody dim-botCardBody' },
203
+ h('div', { className: 'dxw-accountTop dim-botCardTop' },
204
+ h('div', { className: 'dxw-accountIdentity dim-botIdentity' },
205
+ h('div', { className: 'dxw-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 27 })),
206
+ h('div', { className: 'dim-botName' }, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
207
+ h('div', { className: 'dxw-health dim-botHealth' },
208
+ h('span', { className: 'dxw-dot dim-healthDot', 'data-tone': tone }),
209
+ h('span', null, account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'))),
210
+ h('dl', { className: 'dxw-metrics dim-botMetrics' },
211
+ h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '消息通道'),
212
+ h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
213
+ h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'),
214
+ h('dd', null, checkedTime(account.health.lastCheckedAt)))),
215
+ h('div', { className: 'dxw-accountFooter dim-cardFooter' },
216
+ summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
217
+ h('div', { className: 'dxw-actions dim-cardActions' },
218
+ h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
219
+ busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
220
+ h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
221
+ removing ? h('div', { className: 'dxw-confirm dim-confirm', role: 'alertdialog' },
222
+ h('strong', null, '从此 Harness 移除这个微信账号?'),
223
+ h('p', null, '这会停止消息连接,并删除本机保存的 bot_token、账号配置和会话映射。其他微信账号不受影响。'),
224
+ h('div', { className: 'dxw-actions dim-viewActions' },
225
+ h(Button, { onClick: onCancelRemove, disabled: busy === 'delete' }, '保留账号'),
226
+ h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: busy === 'delete' },
227
+ busy === 'delete' ? '正在移除…' : '确认移除')))
228
+ : null);
229
+ }
230
+
231
+ function AccountList(props) {
232
+ return h('section', { className: 'dim-listSection' },
233
+ h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号')),
234
+ h('ul', { className: 'dxw-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
235
+ h(AccountCard, {
236
+ account,
237
+ busy: props.busyByBot[account.botId],
238
+ removing: props.removeTarget === account.botId,
239
+ onReconnect: () => props.onReconnect(account),
240
+ onRequestRemove: () => props.onRequestRemove(account),
241
+ onConfirmRemove: () => props.onConfirmRemove(account),
242
+ onCancelRemove: props.onCancelRemove,
243
+ })))));
244
+ }
245
+
246
+ const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
247
+
248
+ export function mergeWeixinProvisioningSnapshot(
249
+ current,
250
+ incoming,
251
+ { restoreProvisioning = false } = {},
252
+ ) {
253
+ if (!incoming || (!current && !restoreProvisioning)) return current;
254
+ if (current && current.attemptId !== incoming.attemptId) return current;
255
+ return {
256
+ ...current,
257
+ ...incoming,
258
+ durationMs: current?.durationMs ?? 5 * 60_000,
259
+ };
260
+ }
261
+
262
+ export function WeixinSettingsTab({ rpcCall }) {
263
+ const [model, setModel] = React.useState({
264
+ phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
265
+ });
266
+ const [provision, setProvision] = React.useState(null);
267
+ const [busy, setBusy] = React.useState(false);
268
+ const [busyByBot, setBusyByBot] = React.useState({});
269
+ const [removeTarget, setRemoveTarget] = React.useState(null);
270
+ const [notice, setNotice] = React.useState('');
271
+ const [now, setNow] = React.useState(() => Date.now());
272
+ const addButtonRef = React.useRef(null);
273
+ const scheduleAnimationFrame = useAnimationFrameScheduler();
274
+
275
+ const announce = React.useCallback((value) => {
276
+ setNotice('');
277
+ scheduleAnimationFrame(() => {
278
+ if (value) setNotice(value);
279
+ }, 'announcement');
280
+ }, [scheduleAnimationFrame]);
281
+ const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
282
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
283
+ }, [rpcCall]);
284
+ const loadStatus = React.useCallback(async ({
285
+ signal,
286
+ silent = false,
287
+ restoreProvisioning = false,
288
+ } = {}) => {
289
+ if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
290
+ try {
291
+ const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
292
+ if (signal?.aborted) return undefined;
293
+ setModel({
294
+ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
295
+ revision: snapshot.revision, error: null,
296
+ });
297
+ if (snapshot.provisioning) {
298
+ setProvision((current) => mergeWeixinProvisioningSnapshot(
299
+ current,
300
+ snapshot.provisioning,
301
+ { restoreProvisioning },
302
+ ));
303
+ }
304
+ return snapshot;
305
+ } catch (error) {
306
+ if (signal?.aborted || error?.name === 'AbortError') return undefined;
307
+ setModel((current) => ({
308
+ ...current,
309
+ phase: silent && current.phase === 'ready' ? 'ready' : 'error',
310
+ error: presentError(error),
311
+ }));
312
+ return undefined;
313
+ }
314
+ }, [invoke]);
315
+
316
+ React.useEffect(() => {
317
+ const controller = new AbortController();
318
+ void loadStatus({ signal: controller.signal, restoreProvisioning: true });
319
+ return () => controller.abort();
320
+ }, [loadStatus]);
321
+
322
+ React.useEffect(() => {
323
+ if (model.phase !== 'ready') return undefined;
324
+ const controller = new AbortController();
325
+ let running = false;
326
+ const timer = window.setInterval(async () => {
327
+ if (running) return;
328
+ running = true;
329
+ await loadStatus({
330
+ signal: controller.signal,
331
+ silent: true,
332
+ restoreProvisioning: false,
333
+ });
334
+ running = false;
335
+ }, 15_000);
336
+ return () => {
337
+ controller.abort();
338
+ window.clearInterval(timer);
339
+ };
340
+ }, [loadStatus, model.phase]);
341
+
342
+ React.useEffect(() => {
343
+ if (!provision || !['pending', 'scanned'].includes(provision.status)) return undefined;
344
+ const timer = window.setInterval(() => setNow(Date.now()), 1_000);
345
+ return () => window.clearInterval(timer);
346
+ }, [provision?.attemptId, provision?.status]);
347
+
348
+ const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
349
+ setBusy(true);
350
+ try {
351
+ if (replace && provision?.attemptId) {
352
+ await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
353
+ }
354
+ setProvision({ status: 'starting' });
355
+ const started = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
356
+ setNow(Date.now());
357
+ setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
358
+ announce('微信二维码已生成,请使用手机微信扫描。');
359
+ } catch (error) {
360
+ setProvision({
361
+ status: 'failed',
362
+ error: presentError(error),
363
+ ...(provision?.attemptId ? { attemptId: provision.attemptId } : {}),
364
+ });
365
+ } finally {
366
+ setBusy(false);
367
+ }
368
+ }, [announce, invoke, provision?.attemptId]);
369
+
370
+ const cancelProvisioning = React.useCallback(async () => {
371
+ setBusy(true);
372
+ try {
373
+ if (provision?.attemptId && !['failed', 'expired', 'cancelled'].includes(provision.status)) {
374
+ await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
375
+ }
376
+ setProvision(null);
377
+ announce('已取消微信绑定。');
378
+ scheduleAnimationFrame(() => addButtonRef.current?.focus(), 'focus');
379
+ } catch (error) {
380
+ setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
381
+ } finally {
382
+ setBusy(false);
383
+ }
384
+ }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]);
385
+
386
+ const submitVerification = React.useCallback(async (verifyCode) => {
387
+ if (!provision?.attemptId) return;
388
+ setBusy(true);
389
+ try {
390
+ const next = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.submitVerification, {
391
+ attemptId: provision.attemptId,
392
+ verifyCode,
393
+ }));
394
+ setProvision((current) => ({ ...current, ...next }));
395
+ announce('配对码已提交,正在等待微信确认。');
396
+ } catch (error) {
397
+ setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
398
+ } finally {
399
+ setBusy(false);
400
+ }
401
+ }, [announce, invoke, provision?.attemptId]);
402
+
403
+ React.useEffect(() => {
404
+ const attemptId = provision?.attemptId;
405
+ if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
406
+ const controller = new AbortController();
407
+ const scheduler = createPollScheduler({
408
+ setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs),
409
+ clearTimeoutFn: (timer) => window.clearTimeout(timer),
410
+ });
411
+ const poll = async () => {
412
+ try {
413
+ const result = normalizeProvisioning(await invoke(
414
+ WEIXIN_ENDPOINTS.pollProvisioning,
415
+ { attemptId },
416
+ controller.signal,
417
+ ));
418
+ if (scheduler.disposed) return;
419
+ if (result.status === 'connected') {
420
+ const snapshot = await loadStatus({
421
+ signal: controller.signal,
422
+ silent: true,
423
+ restoreProvisioning: false,
424
+ });
425
+ if (scheduler.disposed) return;
426
+ const account = snapshot?.bots.find((bot) => bot.botId === result.botId);
427
+ if (!account?.connected) {
428
+ setProvision((current) => current?.attemptId === attemptId
429
+ ? { ...current, ...result, status: 'connecting' }
430
+ : current);
431
+ scheduler.schedule(poll, result.pollIntervalMs);
432
+ return;
433
+ }
434
+ setProvision(null);
435
+ announce(result.alreadyConnected
436
+ ? '这个微信账号已经绑定并保持在线。'
437
+ : '微信已绑定,可以开始向已绑定的机器人发消息。');
438
+ return;
439
+ }
440
+ setProvision((current) => current?.attemptId === attemptId
441
+ ? { ...current, ...result, durationMs: current.durationMs }
442
+ : current);
443
+ if (['pending', 'scanned', 'connecting'].includes(result.status)) {
444
+ scheduler.schedule(poll, result.pollIntervalMs);
445
+ }
446
+ } catch (error) {
447
+ if (scheduler.disposed || error?.name === 'AbortError') return;
448
+ setProvision((current) => current?.attemptId === attemptId
449
+ ? { ...current, status: 'failed', error: presentError(error) }
450
+ : current);
451
+ }
452
+ };
453
+ scheduler.schedule(poll, provision.pollIntervalMs ?? 1_000);
454
+ return () => {
455
+ scheduler.dispose();
456
+ controller.abort();
457
+ };
458
+ }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);
459
+
460
+ const setBotBusy = React.useCallback((botId, value) => {
461
+ setBusyByBot((current) => {
462
+ const next = { ...current };
463
+ if (value) next[botId] = value;
464
+ else delete next[botId];
465
+ return next;
466
+ });
467
+ }, []);
468
+
469
+ const reconnect = React.useCallback(async (account) => {
470
+ setBotBusy(account.botId, 'reconnect');
471
+ try {
472
+ const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
473
+ setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
474
+ const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
475
+ announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
476
+ } catch (error) {
477
+ announce(`连接检查失败:${presentError(error).message}`);
478
+ } finally {
479
+ setBotBusy(account.botId, null);
480
+ }
481
+ }, [announce, invoke, setBotBusy]);
482
+
483
+ const remove = React.useCallback(async (account) => {
484
+ setBotBusy(account.botId, 'delete');
485
+ try {
486
+ const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
487
+ botId: account.botId,
488
+ confirm: true,
489
+ }));
490
+ setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
491
+ setRemoveTarget(null);
492
+ announce('微信账号及本机凭据已移除。');
493
+ } catch (error) {
494
+ announce(`移除失败:${presentError(error).message}`);
495
+ } finally {
496
+ setBotBusy(account.botId, null);
497
+ }
498
+ }, [announce, invoke, setBotBusy]);
499
+
500
+ let provisionView = null;
501
+ if (provision?.status === 'starting') {
502
+ provisionView = h(ProgressPanel, { busy });
503
+ } else if (['pending', 'scanned'].includes(provision?.status)) {
504
+ provisionView = h(QrPanel, {
505
+ provision, now, busy,
506
+ onRefresh: () => void startProvisioning({ replace: true }),
507
+ onCancel: () => void cancelProvisioning(),
508
+ });
509
+ } else if (provision?.status === 'needs_verification') {
510
+ provisionView = h(VerificationPanel, {
511
+ provision, busy,
512
+ onSubmit: (code) => void submitVerification(code),
513
+ onCancel: () => void cancelProvisioning(),
514
+ });
515
+ } else if (provision?.status === 'connecting') {
516
+ provisionView = h(ProgressPanel, {
517
+ scanned: true, busy, onCancel: () => void cancelProvisioning(),
518
+ });
519
+ } else if (provision && ['failed', 'expired', 'cancelled'].includes(provision.status)) {
520
+ provisionView = h(ProvisionError, {
521
+ provision, busy,
522
+ onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
523
+ onClose: () => void cancelProvisioning(),
524
+ });
525
+ }
526
+
527
+ return h('section', { className: 'dxw-page dim-channelPage', 'aria-label': '微信设置' },
528
+ h(Heading, {
529
+ totals: model.totals,
530
+ adding: Boolean(provision),
531
+ busy,
532
+ onAdd: () => void startProvisioning(),
533
+ addButtonRef,
534
+ }),
535
+ h('div', { className: 'dxw-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
536
+ model.error && model.phase === 'ready'
537
+ ? h('div', { className: 'dxw-statusNotice dim-statusNotice' }, `状态刷新失败:${model.error.message}`)
538
+ : null,
539
+ model.phase === 'loading'
540
+ ? h(LoadingView)
541
+ : model.phase === 'error'
542
+ ? h('div', { className: 'dxw-card dim-surfaceCard' },
543
+ h('div', { className: 'dxw-error dim-inlineError' },
544
+ h('h3', null, '无法读取微信状态'),
545
+ h('p', null, model.error?.message ?? '请稍后重试'),
546
+ h(Button, { onClick: () => void loadStatus() }, '重新读取')))
547
+ : h(React.Fragment, null,
548
+ provisionView,
549
+ model.bots.length === 0 && !provision
550
+ ? h(EmptyView, { onStart: () => void startProvisioning(), busy })
551
+ : null,
552
+ model.bots.length > 0
553
+ ? h(AccountList, {
554
+ bots: model.bots,
555
+ busyByBot,
556
+ removeTarget,
557
+ onReconnect: (account) => void reconnect(account),
558
+ onRequestRemove: (account) => setRemoveTarget(account.botId),
559
+ onConfirmRemove: (account) => void remove(account),
560
+ onCancelRemove: () => setRemoveTarget(null),
561
+ })
562
+ : null),
563
+ );
564
+ }
565
+
566
+ export function apply(ctx) {
567
+ ctx.effect(() => installWeixinStyles(), 'weixin-settings: install client styles');
568
+ const rpcCall = (endpoint, payload, signal) =>
569
+ ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
570
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
571
+ name: 'settings.plugins.tab',
572
+ id: 'weixin',
573
+ order: 30,
574
+ label: '微信',
575
+ inject: () => ({ rpcCall }),
576
+ }, WeixinSettingsTab));
577
+ }