@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,402 @@
1
+ import * as React from 'react';
2
+
3
+ import { WecomLogoGlyph } from '../../channel-logos.js';
4
+ import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
5
+ import { installDingtalkStyles } from '../dingtalk/styles.js';
6
+ import {
7
+ WECOM_ENDPOINTS,
8
+ WECOM_RPC_CHANNEL,
9
+ formatRemaining,
10
+ normalizeProvisioning,
11
+ normalizeSnapshot,
12
+ presentError,
13
+ safeQrSource,
14
+ unwrapRpcResult,
15
+ } from './api.js';
16
+ import { installWecomStyles } from './styles.js';
17
+
18
+ const h = React.createElement;
19
+ const ACTIVE_STATES = new Set(['pending', 'refreshing', 'connecting']);
20
+
21
+ const Button = React.forwardRef(function Button({ children, kind = 'secondary', className = '', ...props }, ref) {
22
+ return h('button', {
23
+ ...props,
24
+ ref,
25
+ type: 'button',
26
+ className: `ddt-button ${className}`.trim(),
27
+ 'data-kind': kind,
28
+ }, children);
29
+ });
30
+
31
+ function checkedTime(value) {
32
+ if (!value) return '尚未检查';
33
+ try {
34
+ return new Intl.DateTimeFormat('zh-CN', {
35
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
36
+ }).format(new Date(value));
37
+ } catch {
38
+ return '刚刚';
39
+ }
40
+ }
41
+
42
+ function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) {
43
+ return h('div', { className: 'ddt-heading' },
44
+ h('div', { className: 'ddt-tools' },
45
+ h('div', { className: 'dim-bindActions' },
46
+ h(Button, {
47
+ kind: 'primary',
48
+ className: 'dim-scanButton',
49
+ onClick: onAdd,
50
+ disabled: adding || busy,
51
+ ref: addButtonRef,
52
+ 'aria-label': '扫码接入企业微信机器人',
53
+ }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
54
+ h(Button, {
55
+ kind: 'credential',
56
+ className: 'dim-credentialButton',
57
+ onClick: onCredential,
58
+ disabled: adding || busy,
59
+ 'aria-pressed': credentialOpen,
60
+ 'aria-label': '使用 Bot ID 和 Secret 绑定企业微信机器人',
61
+ }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
62
+ totals.configured > 0
63
+ ? h('div', { className: 'ddt-badge dim-onlineBadge' },
64
+ h('span', null, `${totals.connected} / ${totals.configured} 在线`))
65
+ : null));
66
+ }
67
+
68
+ function LoadingView() {
69
+ return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
70
+ h('div', { className: 'ddt-spinner dim-spinner' }),
71
+ h('span', null, '正在读取企业微信机器人状态…'));
72
+ }
73
+
74
+ function EmptyView({ busy, onStart }) {
75
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
76
+ h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
77
+ h('div', { className: 'dim-emptyCopy' },
78
+ h('div', { className: 'ddt-stateLabel dim-stateLabel' },
79
+ h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未绑定企业微信机器人')),
80
+ h('h3', null, '使用企业微信 App 扫码创建智能机器人'),
81
+ h('p', null, '扫码由腾讯官方页面完成,不需要手动填写 Bot ID 或 Secret。创建成功后,机器人会自动连接 DeepSeek Harness。'),
82
+ h('div', { className: 'ddt-actions dim-viewActions' },
83
+ h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
84
+ busy ? '正在生成二维码…' : '生成企业微信二维码'))),
85
+ h('div', { className: 'ddt-brandMark dim-emptyBrand dwecom-brand', 'aria-hidden': 'true' },
86
+ h(WecomLogoGlyph, { size: 64 }))));
87
+ }
88
+
89
+ function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
90
+ const source = safeQrSource(provision.qrCodeDataUrl);
91
+ const remaining = Math.max(0, provision.expiresAt - now);
92
+ const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
93
+ const progress = Math.round(Math.min(1, remaining / duration) * 100);
94
+ const refreshing = provision.status === 'refreshing';
95
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
96
+ h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' },
97
+ h('div', { className: 'ddt-qrColumn dim-qrColumn' },
98
+ h('div', { className: 'ddt-qrFrame dim-qrFrame' },
99
+ source ? h('img', { src: source, alt: '用于绑定企业微信机器人的一次性二维码' })
100
+ : h('div', { className: 'ddt-qrFallback dim-qrFallback' },
101
+ refreshing ? '二维码正在自动刷新…' : '二维码图片正在生成…')),
102
+ h('div', { className: 'ddt-countdown dim-countdown' },
103
+ h('div', { className: 'ddt-countdownTop dim-countdownTop' },
104
+ h('span', null, '当前二维码有效时间'),
105
+ h('strong', null, refreshing ? '--:--' : formatRemaining(remaining))),
106
+ h('div', { className: 'ddt-progress dim-progress', style: { '--ddt-progress': `${progress}%` } }, h('span')))),
107
+ h('div', { className: 'ddt-qrCopy dim-qrCopy' },
108
+ h('div', { className: 'ddt-stateLabel dim-stateLabel' },
109
+ h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }),
110
+ h('span', null, refreshing ? '正在刷新二维码' : '等待企业微信 App 扫码')),
111
+ h('h3', null, '使用企业微信 App 完成智能机器人授权'),
112
+ h('p', null, '企业微信官方页面会创建一个智能机器人,并把连接凭据安全交给本机 Harness Host。'),
113
+ h('ol', { className: 'ddt-steps dim-steps' },
114
+ h('li', null, '打开企业微信 App,扫描左侧二维码'),
115
+ h('li', null, '在腾讯授权页面确认创建智能机器人'),
116
+ h('li', null, '返回这里等待连接完成')),
117
+ h('div', { className: 'ddt-actions dim-viewActions' },
118
+ h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'),
119
+ h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消')))));
120
+ }
121
+
122
+ function ProvisionView({ provision, busy, onRetry, onClose }) {
123
+ if (provision.status === 'connecting') {
124
+ return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView', 'aria-busy': 'true' },
125
+ h('div', { className: 'ddt-spinner dim-spinner' }),
126
+ h('h3', null, '企业微信已授权,正在连接机器人'),
127
+ h('p', null, '凭据正在写入本机,并启动企业微信 WebSocket 消息连接。'));
128
+ }
129
+ const error = provision.error ?? { code: 'WECOM_PROVISION_FAILED', message: '企业微信机器人没有绑定完成' };
130
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
131
+ h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
132
+ h('h3', null, '企业微信机器人没有绑定完成'),
133
+ h('p', null, error.message),
134
+ h('span', { className: 'ddt-errorCode' }, error.code),
135
+ h('div', { className: 'ddt-actions dim-viewActions' },
136
+ h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
137
+ h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
138
+ }
139
+
140
+ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
141
+ return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
142
+ h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
143
+ h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。企业微信平台中的机器人不会被自动删除。'),
144
+ h('div', { className: 'ddt-actions dim-viewActions' },
145
+ h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'),
146
+ h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
147
+ }
148
+
149
+ export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
150
+ const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
151
+ const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
152
+ const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
153
+ return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
154
+ h('div', { className: 'ddt-cardBody dim-botCardBody' },
155
+ h('div', { className: 'ddt-accountTop dim-botCardTop' },
156
+ h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
157
+ h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
158
+ h('div', { className: 'dim-botName' },
159
+ h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
160
+ h('div', { className: 'ddt-health dim-botHealth' },
161
+ h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))),
162
+ h('dl', { className: 'ddt-metrics dim-botMetrics' },
163
+ h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
164
+ h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
165
+ h('div', { className: 'ddt-accountFooter dim-cardFooter' },
166
+ summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
167
+ h('div', { className: 'ddt-actions dim-cardActions' },
168
+ h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
169
+ h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
170
+ removing ? h(RemoveConfirmation, {
171
+ account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove,
172
+ }) : null);
173
+ }
174
+
175
+ export function WecomSettingsTab({ rpcCall }) {
176
+ const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null });
177
+ const [provision, setProvision] = React.useState(null);
178
+ const [busy, setBusy] = React.useState(false);
179
+ const [busyByBot, setBusyByBot] = React.useState({});
180
+ const [removeTarget, setRemoveTarget] = React.useState(null);
181
+ const [credentialOpen, setCredentialOpen] = React.useState(false);
182
+ const [credentialError, setCredentialError] = React.useState(null);
183
+ const [now, setNow] = React.useState(Date.now());
184
+ const mounted = React.useRef(true);
185
+ const addButtonRef = React.useRef(null);
186
+
187
+ React.useEffect(() => {
188
+ const disposeDingtalk = installDingtalkStyles();
189
+ const disposeWecom = installWecomStyles();
190
+ mounted.current = true;
191
+ return () => {
192
+ mounted.current = false;
193
+ disposeWecom();
194
+ disposeDingtalk();
195
+ };
196
+ }, []);
197
+
198
+ const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
199
+ if (typeof rpcCall !== 'function') throw new TypeError('企业微信设置页缺少 RPC 连接');
200
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
201
+ }, [rpcCall]);
202
+
203
+ const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
204
+ if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
205
+ try {
206
+ const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
207
+ if (!mounted.current || signal?.aborted) return undefined;
208
+ setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
209
+ if (restore && snapshot.provisioning) setProvision({
210
+ ...snapshot.provisioning,
211
+ durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
212
+ });
213
+ return snapshot;
214
+ } catch (error) {
215
+ if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
216
+ setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
217
+ }
218
+ return undefined;
219
+ }
220
+ }, [invoke]);
221
+
222
+ React.useEffect(() => {
223
+ const controller = new AbortController();
224
+ void loadStatus({ signal: controller.signal, restore: true });
225
+ return () => controller.abort();
226
+ }, [loadStatus]);
227
+
228
+ React.useEffect(() => {
229
+ if (model.phase !== 'ready') return undefined;
230
+ const controller = new AbortController();
231
+ const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15_000);
232
+ return () => { controller.abort(); window.clearInterval(timer); };
233
+ }, [loadStatus, model.phase]);
234
+
235
+ React.useEffect(() => {
236
+ if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined;
237
+ const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000);
238
+ return () => window.clearInterval(timer);
239
+ }, [provision?.attemptId, provision?.status]);
240
+
241
+ const startProvisioning = React.useCallback(async (replace = false) => {
242
+ setCredentialOpen(false);
243
+ setCredentialError(null);
244
+ setBusy(true);
245
+ try {
246
+ if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
247
+ if (!mounted.current) return;
248
+ setProvision({ status: 'starting' });
249
+ const started = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
250
+ if (!mounted.current) return;
251
+ setNow(Date.now());
252
+ setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
253
+ } catch (error) {
254
+ if (mounted.current) setProvision({ status: 'failed', error: presentError(error) });
255
+ } finally {
256
+ if (mounted.current) setBusy(false);
257
+ }
258
+ }, [invoke, provision?.attemptId]);
259
+
260
+ const bindCredentials = React.useCallback(async ({ identity, secret }) => {
261
+ setBusy(true);
262
+ setCredentialError(null);
263
+ try {
264
+ const snapshot = normalizeSnapshot(await invoke(
265
+ WECOM_ENDPOINTS.bindCredentials,
266
+ { botId: identity, secret },
267
+ ));
268
+ if (!mounted.current) return;
269
+ setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
270
+ setCredentialOpen(false);
271
+ } catch (error) {
272
+ if (mounted.current) setCredentialError(presentError(error));
273
+ } finally {
274
+ if (mounted.current) setBusy(false);
275
+ }
276
+ }, [invoke]);
277
+
278
+ const closeProvision = React.useCallback(async () => {
279
+ setBusy(true);
280
+ try {
281
+ if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) {
282
+ await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
283
+ }
284
+ if (mounted.current) setProvision(null);
285
+ } finally {
286
+ if (mounted.current) setBusy(false);
287
+ }
288
+ }, [invoke, provision?.attemptId, provision?.status]);
289
+
290
+ React.useEffect(() => {
291
+ const attemptId = provision?.attemptId;
292
+ if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined;
293
+ const controller = new AbortController();
294
+ let disposed = false;
295
+ let timer;
296
+ const poll = async () => {
297
+ try {
298
+ const current = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal));
299
+ if (disposed || controller.signal.aborted || !mounted.current) return;
300
+ if (current.status === 'connected') {
301
+ setProvision(null);
302
+ await loadStatus({ signal: controller.signal, silent: true });
303
+ return;
304
+ }
305
+ setProvision((previous) => previous?.attemptId === attemptId
306
+ ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision
307
+ ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs }
308
+ : previous);
309
+ if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs);
310
+ } catch (error) {
311
+ if (!disposed && !controller.signal.aborted && mounted.current) {
312
+ setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
313
+ }
314
+ }
315
+ };
316
+ timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
317
+ return () => { disposed = true; controller.abort(); window.clearTimeout(timer); };
318
+ }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
319
+
320
+ const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
321
+ setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
322
+ try {
323
+ const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
324
+ if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
325
+ return snapshot;
326
+ } finally {
327
+ if (mounted.current) setBusyByBot((current) => {
328
+ const next = { ...current }; delete next[account.botId]; return next;
329
+ });
330
+ }
331
+ }, [invoke]);
332
+
333
+ let provisionView = null;
334
+ if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
335
+ else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
336
+ provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision(),
337
+ });
338
+ else if (provision) provisionView = h(ProvisionView, {
339
+ provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision(),
340
+ });
341
+
342
+ const botList = model.bots.length > 0
343
+ ? h('section', { className: 'dim-listSection' },
344
+ h('div', { className: 'ddt-listHeading dim-listHeading' },
345
+ h('h3', null, '已绑定的企业微信机器人')),
346
+ h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
347
+ h('li', { key: account.botId }, h(AccountCard, {
348
+ account,
349
+ busy: busyByBot[account.botId],
350
+ removing: removeTarget === account.botId,
351
+ onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
352
+ onRequestRemove: () => setRemoveTarget(account.botId),
353
+ onCancelRemove: () => setRemoveTarget(null),
354
+ onConfirmRemove: async () => {
355
+ await botAction(account, 'delete', WECOM_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true });
356
+ if (mounted.current) setRemoveTarget(null);
357
+ },
358
+ })))))
359
+ : null;
360
+
361
+ const credentialView = credentialOpen
362
+ ? h(CredentialBindingPanel, {
363
+ channel: '企业微信',
364
+ identityLabel: 'Bot ID',
365
+ identityPlaceholder: '填写企业微信智能机器人 Bot ID',
366
+ secretLabel: 'Secret',
367
+ secretPlaceholder: '填写企业微信智能机器人 Secret',
368
+ busy,
369
+ error: credentialError,
370
+ onSubmit: bindCredentials,
371
+ onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
372
+ })
373
+ : null;
374
+
375
+ return h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' },
376
+ h(Heading, {
377
+ totals: model.totals,
378
+ adding: Boolean(provision),
379
+ busy,
380
+ onAdd: () => void startProvisioning(),
381
+ onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
382
+ credentialOpen,
383
+ addButtonRef,
384
+ }),
385
+ model.phase === 'loading' ? h(LoadingView)
386
+ : model.phase === 'error'
387
+ ? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
388
+ : h(React.Fragment, null,
389
+ credentialView,
390
+ provisionView,
391
+ model.bots.length === 0 && !provision && !credentialOpen
392
+ ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
393
+ botList));
394
+ }
395
+
396
+ export function apply(ctx) {
397
+ ctx.effect(() => installWecomStyles(), 'wecom-settings: install client styles');
398
+ const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
399
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
400
+ name: 'settings.plugins.tab', id: 'wecom', order: 45, label: '企业微信', inject: () => ({ rpcCall }),
401
+ }, WecomSettingsTab));
402
+ }
@@ -0,0 +1,19 @@
1
+ export const WECOM_STYLE_ID = 'xmanrui-dsh-im-wecom-settings';
2
+
3
+ const CSS = String.raw`
4
+ .dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; }
5
+ .dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
6
+ .dwecom-avatar svg, .dwecom-brand svg { display: block; }
7
+ `;
8
+
9
+ export function installWecomStyles() {
10
+ if (typeof document === 'undefined') return () => {};
11
+ const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`);
12
+ if (existing) return () => {};
13
+ const style = document.createElement('style');
14
+ style.dataset.plugin = '@xmanrui/dsh-im';
15
+ style.dataset.pluginCss = WECOM_STYLE_ID;
16
+ style.textContent = CSS;
17
+ document.head.appendChild(style);
18
+ return () => style.remove();
19
+ }
@@ -0,0 +1,156 @@
1
+ export const WEIXIN_RPC_CHANNEL = '/weixin';
2
+ export const WEIXIN_ENDPOINTS = Object.freeze({
3
+ status: 'connection.status',
4
+ beginProvisioning: 'provision.begin',
5
+ pollProvisioning: 'provision.poll',
6
+ submitVerification: 'provision.verify',
7
+ cancelProvisioning: 'provision.cancel',
8
+ reconnectBot: 'bot.reconnect',
9
+ deleteBot: 'bot.delete',
10
+ });
11
+
12
+ const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
13
+ const PROVISION_STATES = new Set([
14
+ 'starting',
15
+ 'pending',
16
+ 'scanned',
17
+ 'needs_verification',
18
+ 'connecting',
19
+ 'connected',
20
+ 'expired',
21
+ 'failed',
22
+ 'cancelled',
23
+ ]);
24
+
25
+ function isRecord(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+
29
+ function string(value, fallback = '') {
30
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
31
+ }
32
+
33
+ function timestamp(value) {
34
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
35
+ }
36
+
37
+ export function unwrapRpcResult(result) {
38
+ if (!isRecord(result) || typeof result.ok !== 'boolean') {
39
+ throw new Error('微信服务返回了无法识别的响应');
40
+ }
41
+ if (!result.ok) {
42
+ const error = new Error(string(result.error?.message, '微信操作失败'));
43
+ error.code = string(result.error?.code, 'WEIXIN_RPC_ERROR');
44
+ throw error;
45
+ }
46
+ return result.value;
47
+ }
48
+
49
+ export function safeQrSource(value) {
50
+ return typeof value === 'string'
51
+ && /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
52
+ ? value
53
+ : undefined;
54
+ }
55
+
56
+ export function safeVerificationUrl(value) {
57
+ if (typeof value !== 'string') return undefined;
58
+ try {
59
+ const url = new URL(value);
60
+ const host = url.hostname.toLowerCase();
61
+ return url.protocol === 'https:'
62
+ && (host === 'weixin.qq.com' || host.endsWith('.weixin.qq.com'))
63
+ ? url.toString()
64
+ : undefined;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function normalizeProvisioning(value) {
71
+ if (!isRecord(value) || !string(value.attemptId)) {
72
+ throw new Error('微信扫码服务没有返回有效的绑定任务');
73
+ }
74
+ const status = PROVISION_STATES.has(value.status) ? value.status : 'failed';
75
+ const result = {
76
+ attemptId: string(value.attemptId),
77
+ status,
78
+ expiresAt: timestamp(value.expiresAt) ?? Date.now(),
79
+ pollIntervalMs: Math.min(5_000, Math.max(500, Number(value.pollIntervalMs) || 1_000)),
80
+ verificationRequired: value.verificationRequired === true || status === 'needs_verification',
81
+ };
82
+ const verificationUrl = safeVerificationUrl(value.verificationUrl);
83
+ const qrCodeDataUrl = safeQrSource(value.qrCodeDataUrl);
84
+ if (verificationUrl) result.verificationUrl = verificationUrl;
85
+ if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
86
+ if (string(value.botId)) result.botId = string(value.botId);
87
+ if (value.alreadyConnected === true) result.alreadyConnected = true;
88
+ if (isRecord(value.error)) {
89
+ result.error = {
90
+ code: string(value.error.code, 'WEIXIN_PROVISION_FAILED'),
91
+ message: string(value.error.message, '微信绑定没有完成'),
92
+ };
93
+ }
94
+ return result;
95
+ }
96
+
97
+ function normalizeBot(value) {
98
+ if (!isRecord(value) || !string(value.botId) || !isRecord(value.bot)) return null;
99
+ const state = ACCOUNT_STATES.has(value.state) ? value.state : 'error';
100
+ const connected = value.connected === true;
101
+ return {
102
+ botId: string(value.botId),
103
+ state: connected ? 'connected' : state,
104
+ connected,
105
+ configured: value.configured === true,
106
+ bot: {
107
+ name: string(value.bot.name, '微信机器人'),
108
+ accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
109
+ },
110
+ health: {
111
+ status: string(value.health?.status, connected ? 'healthy' : 'offline'),
112
+ summary: string(value.health?.summary, connected ? '微信连接正常' : '微信连接未就绪'),
113
+ lastCheckedAt: timestamp(value.health?.lastCheckedAt),
114
+ },
115
+ stats: {
116
+ messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
117
+ messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
118
+ },
119
+ error: isRecord(value.error)
120
+ ? {
121
+ code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
122
+ message: string(value.error.message, '微信连接未就绪'),
123
+ }
124
+ : null,
125
+ };
126
+ }
127
+
128
+ export function normalizeSnapshot(value) {
129
+ if (!isRecord(value) || !Array.isArray(value.bots)) {
130
+ throw new Error('微信服务没有返回有效的账号列表');
131
+ }
132
+ const bots = value.bots.map(normalizeBot).filter(Boolean);
133
+ return {
134
+ schemaVersion: Number(value.schemaVersion) || 1,
135
+ revision: Number(value.revision) || 0,
136
+ state: string(value.state, 'offline'),
137
+ bots,
138
+ totals: {
139
+ configured: bots.length,
140
+ connected: bots.filter((bot) => bot.connected).length,
141
+ },
142
+ provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
143
+ };
144
+ }
145
+
146
+ export function presentError(error) {
147
+ return {
148
+ code: string(error?.code, 'WEIXIN_ERROR'),
149
+ message: string(error?.message, '微信操作失败,请稍后重试'),
150
+ };
151
+ }
152
+
153
+ export function formatRemaining(milliseconds) {
154
+ const seconds = Math.max(0, Math.ceil(milliseconds / 1_000));
155
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
156
+ }