@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,709 @@
1
+ import * as React from 'react';
2
+
3
+ import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
4
+ import {
5
+ DINGTALK_ENDPOINTS,
6
+ DINGTALK_RPC_CHANNEL,
7
+ formatRemaining,
8
+ normalizeProvisioning,
9
+ normalizeSnapshot,
10
+ presentError,
11
+ safeQrSource,
12
+ unwrapRpcResult,
13
+ } from './api.js';
14
+ import { installDingtalkStyles } from './styles.js';
15
+
16
+ const h = React.createElement;
17
+ const ACTIVE_PROVISION_STATES = new Set(['pending', 'scanned', 'authorizing', 'creating', 'connecting']);
18
+
19
+ export const name = 'dingtalk-settings';
20
+ export const inject = ['slots', 'connection'];
21
+
22
+ function DingtalkIcon({ size = 28 }) {
23
+ return h('svg', {
24
+ width: size,
25
+ height: size,
26
+ viewBox: '0 0 48 48',
27
+ fill: 'none',
28
+ xmlns: 'http://www.w3.org/2000/svg',
29
+ 'aria-hidden': 'true',
30
+ focusable: 'false',
31
+ }, h('path', {
32
+ fill: 'currentColor',
33
+ d: 'M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498',
34
+ }));
35
+ }
36
+
37
+ const Button = React.forwardRef(function Button(
38
+ { children, kind = 'secondary', className = '', ...props },
39
+ ref,
40
+ ) {
41
+ return h('button', {
42
+ ...props,
43
+ ref,
44
+ type: 'button',
45
+ className: `ddt-button ${className}`.trim(),
46
+ 'data-kind': kind,
47
+ }, children);
48
+ });
49
+
50
+ function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) {
51
+ return h('div', { className: 'ddt-heading' },
52
+ h('div', { className: 'ddt-headingCopy' },
53
+ h('div', { className: 'ddt-eyebrow' }, 'Channel'),
54
+ h('h2', null, '钉钉机器人'),
55
+ h('p', null, '通过扫码把钉钉机器人接入 DeepSeek Harness')),
56
+ h('div', { className: 'ddt-tools' },
57
+ h('div', { className: 'dim-bindActions' },
58
+ h(Button, {
59
+ kind: 'primary',
60
+ className: 'dim-scanButton',
61
+ onClick: onAdd,
62
+ disabled: adding || busy,
63
+ ref: addButtonRef,
64
+ 'aria-label': '扫码接入钉钉机器人',
65
+ }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
66
+ h(Button, {
67
+ kind: 'credential',
68
+ className: 'dim-credentialButton',
69
+ onClick: onCredential,
70
+ disabled: adding || busy,
71
+ 'aria-pressed': credentialOpen,
72
+ 'aria-label': '使用 Client ID 和 Client Secret 绑定钉钉机器人',
73
+ }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
74
+ totals.configured > 0
75
+ ? h('div', { className: 'ddt-badge dim-onlineBadge' },
76
+ h('span', null, `${totals.connected} / ${totals.configured} 在线`))
77
+ : null));
78
+ }
79
+
80
+ function LoadingView() {
81
+ return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
82
+ h('div', { className: 'ddt-spinner dim-spinner' }),
83
+ h('span', null, '正在读取钉钉连接状态…'));
84
+ }
85
+
86
+ function EmptyView({ busy, onStart }) {
87
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
88
+ h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
89
+ h('div', { className: 'dim-emptyCopy' },
90
+ h('div', { className: 'ddt-stateLabel dim-stateLabel' },
91
+ h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未接入钉钉机器人')),
92
+ h('h3', null, '扫一次码,自动创建并连接机器人'),
93
+ h('p', null, '授权由钉钉官方页面完成。扫码账号必须已加入一个企业/组织并有权创建机器人;创建成功后,应用凭据会直接写入 Harness Host。'),
94
+ h('div', { className: 'ddt-actions dim-viewActions' },
95
+ h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
96
+ busy ? '正在生成二维码…' : '生成钉钉二维码'))),
97
+ h('div', { className: 'ddt-brandMark dim-emptyBrand', 'aria-hidden': 'true' },
98
+ h(DingtalkIcon, { size: 68 }))));
99
+ }
100
+
101
+ function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
102
+ const [imageFailed, setImageFailed] = React.useState(false);
103
+ const source = safeQrSource(provision.qrCodeDataUrl);
104
+ const remaining = Math.max(0, provision.expiresAt - now);
105
+ const expired = remaining === 0 || provision.status === 'expired';
106
+ const duration = Math.max(1, provision.durationMs ?? 10 * 60_000);
107
+ const progress = Math.round(Math.min(1, remaining / duration) * 100);
108
+
109
+ React.useEffect(() => setImageFailed(false), [source]);
110
+
111
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
112
+ h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' },
113
+ h('div', { className: 'ddt-qrColumn dim-qrColumn' },
114
+ h('div', { className: 'ddt-qrFrame dim-qrFrame' },
115
+ source && !imageFailed
116
+ ? h('img', {
117
+ src: source,
118
+ alt: '用于把钉钉机器人接入 DeepSeek Harness 的一次性二维码',
119
+ onError: () => setImageFailed(true),
120
+ })
121
+ : h('div', { className: 'ddt-qrFallback dim-qrFallback' }, '二维码图片未就绪,请重新生成。'),
122
+ expired ? h('div', { className: 'ddt-expired dim-qrExpired' }, '二维码已过期\n请重新生成') : null),
123
+ h('div', { className: 'ddt-countdown dim-countdown' },
124
+ h('div', { className: 'ddt-countdownTop dim-countdownTop' },
125
+ h('span', null, '二维码有效时间'), h('strong', null, formatRemaining(remaining))),
126
+ h('div', { className: 'ddt-progress dim-progress', 'aria-hidden': 'true' },
127
+ h('span', { style: { '--ddt-progress': `${progress}%` } })))),
128
+ h('div', { className: 'ddt-qrCopy dim-qrCopy' },
129
+ h('div', { className: 'ddt-stateLabel dim-stateLabel' },
130
+ h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': expired ? 'error' : 'warning' }),
131
+ h('span', null, expired ? '二维码已失效' : '等待钉钉扫码授权')),
132
+ h('h3', null, expired ? '重新生成二维码后继续' : '使用钉钉 App 完成机器人授权'),
133
+ h('p', null, '扫码账号必须已加入企业/组织。如果钉钉提示尚未加入组织,请在提示页创建组织,或换用已加入组织的账号。'),
134
+ h('ol', { className: 'ddt-steps dim-steps' },
135
+ h('li', null, '使用已加入企业/组织的钉钉账号扫描左侧二维码'),
136
+ h('li', null, '在授权页点击“一键创建新机器人”'),
137
+ h('li', null, '保持本页打开,等待机器人自动连接')),
138
+ h('div', { className: 'ddt-actions dim-viewActions' },
139
+ expired
140
+ ? h(Button, { kind: 'primary', onClick: onRefresh, disabled: busy }, '重新生成二维码')
141
+ : null,
142
+ !expired ? h(Button, { onClick: onRefresh, disabled: busy }, '换一个二维码') : null,
143
+ h(Button, { onClick: onCancel, disabled: busy }, '取消')))));
144
+ }
145
+
146
+ function ProgressPanel({ status, busy, onCancel }) {
147
+ const connecting = status === 'connecting';
148
+ const creating = status === 'creating';
149
+ return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
150
+ h('div', { className: 'ddt-spinner dim-spinner' }),
151
+ h('h3', null, connecting
152
+ ? '机器人已创建,正在建立消息连接'
153
+ : creating ? '授权已确认,正在创建钉钉机器人' : '正在确认钉钉授权'),
154
+ h('p', null, connecting
155
+ ? '正在检查钉钉 Stream 长连接,成功后会自动显示为在线。'
156
+ : '请勿关闭本页,钉钉完成授权后将自动继续。'),
157
+ h('div', { className: 'ddt-actions dim-viewActions', style: { justifyContent: 'center', marginTop: 14 } },
158
+ h(Button, { onClick: onCancel, disabled: busy }, '取消接入')));
159
+ }
160
+
161
+ function ProvisionError({ provision, busy, onRetry, onClose }) {
162
+ const error = provision.error ?? {
163
+ code: 'DINGTALK_PROVISION_FAILED',
164
+ message: '钉钉机器人没有接入完成',
165
+ };
166
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
167
+ h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
168
+ h('h3', null, provision.status === 'expired' ? '二维码已过期' : '钉钉机器人没有接入完成'),
169
+ h('p', null, error.message),
170
+ h('span', { className: 'ddt-errorCode' }, error.code),
171
+ h('div', { className: 'ddt-actions dim-viewActions' },
172
+ h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
173
+ h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
174
+ }
175
+
176
+ function checkedTime(value) {
177
+ if (!value) return '尚未检查';
178
+ try {
179
+ return new Intl.DateTimeFormat('zh-CN', {
180
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
181
+ }).format(new Date(value));
182
+ } catch {
183
+ return '刚刚';
184
+ }
185
+ }
186
+
187
+ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
188
+ const cancelRef = React.useRef(null);
189
+ React.useEffect(() => cancelRef.current?.focus(), []);
190
+ return h('div', {
191
+ className: 'ddt-confirm dim-confirm',
192
+ role: 'alertdialog',
193
+ 'aria-label': `移除${account.bot.name}`,
194
+ onKeyDown: (event) => {
195
+ if (event.key === 'Escape' && !busy) onCancel();
196
+ },
197
+ },
198
+ h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
199
+ h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。钉钉开放平台中的机器人不会被自动删除。'),
200
+ h('div', { className: 'ddt-actions dim-viewActions' },
201
+ h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, '保留机器人'),
202
+ h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy },
203
+ busy ? '正在移除…' : '确认移除接入')));
204
+ }
205
+
206
+ export function AccountCard({
207
+ account,
208
+ busy,
209
+ removing,
210
+ onReconnect,
211
+ onRequestRemove,
212
+ onConfirmRemove,
213
+ onCancelRemove,
214
+ }) {
215
+ const state = busy === 'reconnect' ? 'connecting' : account.state;
216
+ const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
217
+ const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
218
+ const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
219
+ return h('article', { className: 'ddt-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId },
220
+ h('div', { className: 'ddt-cardBody dim-botCardBody' },
221
+ h('div', { className: 'ddt-accountTop dim-botCardTop' },
222
+ h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
223
+ h('div', { className: 'ddt-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(DingtalkIcon, { size: 29 })),
224
+ h('div', { className: 'dim-botName' },
225
+ h('h3', { title: account.bot.name }, account.bot.name),
226
+ h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))),
227
+ h('div', { className: 'ddt-health dim-botHealth' },
228
+ h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))),
229
+ h('dl', { className: 'ddt-metrics dim-botMetrics' },
230
+ h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'),
231
+ h('dd', null, account.connected ? 'Stream 长连接' : '离线')),
232
+ h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'),
233
+ h('dd', null, checkedTime(account.health.lastCheckedAt)))),
234
+ h('div', { className: 'ddt-accountFooter dim-cardFooter' },
235
+ summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
236
+ h('div', { className: 'ddt-actions dim-cardActions' },
237
+ h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
238
+ busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
239
+ h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) },
240
+ '移除接入')))),
241
+ removing ? h(RemoveConfirmation, {
242
+ account,
243
+ busy: busy === 'delete',
244
+ onConfirm: onConfirmRemove,
245
+ onCancel: onCancelRemove,
246
+ }) : null);
247
+ }
248
+
249
+ function AccountList(props) {
250
+ return h('section', { className: 'dim-listSection' },
251
+ h('div', { className: 'ddt-listHeading dim-listHeading' },
252
+ h('h3', null, '已接入的钉钉机器人')),
253
+ h('ul', { className: 'ddt-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
254
+ h(AccountCard, {
255
+ account,
256
+ busy: props.busyByBot[account.botId],
257
+ removing: props.removeTarget === account.botId,
258
+ onReconnect: () => props.onReconnect(account),
259
+ onRequestRemove: () => props.onRequestRemove(account),
260
+ onConfirmRemove: () => props.onConfirmRemove(account),
261
+ onCancelRemove: props.onCancelRemove,
262
+ })))));
263
+ }
264
+
265
+ const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
266
+
267
+ export function DingtalkSettingsTab({ rpcCall }) {
268
+ const [model, setModel] = React.useState({
269
+ phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
270
+ });
271
+ const [provision, setProvision] = React.useState(null);
272
+ const [busy, setBusy] = React.useState(false);
273
+ const [busyByBot, setBusyByBot] = React.useState({});
274
+ const [removeTarget, setRemoveTarget] = React.useState(null);
275
+ const [credentialOpen, setCredentialOpen] = React.useState(false);
276
+ const [credentialError, setCredentialError] = React.useState(null);
277
+ const [notice, setNotice] = React.useState('');
278
+ const [now, setNow] = React.useState(() => Date.now());
279
+ const addButtonRef = React.useRef(null);
280
+ const mountedRef = React.useRef(true);
281
+ const statusRequestRef = React.useRef(0);
282
+ const noticeFrameRef = React.useRef(null);
283
+ const focusFrameRef = React.useRef(null);
284
+
285
+ React.useEffect(() => {
286
+ mountedRef.current = true;
287
+ return () => {
288
+ mountedRef.current = false;
289
+ statusRequestRef.current += 1;
290
+ if (noticeFrameRef.current !== null) {
291
+ window.cancelAnimationFrame(noticeFrameRef.current);
292
+ noticeFrameRef.current = null;
293
+ }
294
+ if (focusFrameRef.current !== null) {
295
+ window.cancelAnimationFrame(focusFrameRef.current);
296
+ focusFrameRef.current = null;
297
+ }
298
+ };
299
+ }, []);
300
+
301
+ React.useEffect(() => installDingtalkStyles(), []);
302
+
303
+ const announce = React.useCallback((message) => {
304
+ if (!mountedRef.current) return;
305
+ if (noticeFrameRef.current !== null) {
306
+ window.cancelAnimationFrame(noticeFrameRef.current);
307
+ noticeFrameRef.current = null;
308
+ }
309
+ setNotice('');
310
+ if (message) {
311
+ noticeFrameRef.current = window.requestAnimationFrame(() => {
312
+ noticeFrameRef.current = null;
313
+ if (mountedRef.current) setNotice(message);
314
+ });
315
+ }
316
+ }, []);
317
+
318
+ const focusAddButton = React.useCallback(() => {
319
+ if (!mountedRef.current) return;
320
+ if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current);
321
+ focusFrameRef.current = window.requestAnimationFrame(() => {
322
+ focusFrameRef.current = null;
323
+ if (mountedRef.current) addButtonRef.current?.focus();
324
+ });
325
+ }, []);
326
+
327
+ const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
328
+ if (typeof rpcCall !== 'function') throw new TypeError('钉钉设置页缺少 RPC 连接');
329
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
330
+ }, [rpcCall]);
331
+
332
+ const loadStatus = React.useCallback(async ({
333
+ signal,
334
+ silent = false,
335
+ restoreProvisioning = false,
336
+ } = {}) => {
337
+ if (!mountedRef.current || signal?.aborted) return undefined;
338
+ const requestId = statusRequestRef.current + 1;
339
+ statusRequestRef.current = requestId;
340
+ const canCommit = () => mountedRef.current
341
+ && !signal?.aborted
342
+ && statusRequestRef.current === requestId;
343
+ if (!silent && canCommit()) {
344
+ setModel((current) => ({ ...current, phase: 'loading', error: null }));
345
+ }
346
+ try {
347
+ const snapshot = normalizeSnapshot(await invoke(DINGTALK_ENDPOINTS.status, {}, signal));
348
+ if (!canCommit()) return undefined;
349
+ setModel({
350
+ phase: 'ready',
351
+ bots: snapshot.bots,
352
+ totals: snapshot.totals,
353
+ revision: snapshot.revision,
354
+ error: null,
355
+ });
356
+ if (restoreProvisioning && snapshot.provisioning) {
357
+ setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
358
+ ? {
359
+ ...current,
360
+ ...snapshot.provisioning,
361
+ durationMs: current?.durationMs
362
+ ?? Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
363
+ }
364
+ : current);
365
+ }
366
+ return snapshot;
367
+ } catch (error) {
368
+ if (error?.name === 'AbortError' || !canCommit()) return undefined;
369
+ setModel((current) => ({
370
+ ...current,
371
+ phase: silent && current.phase === 'ready' ? 'ready' : 'error',
372
+ error: presentError(error),
373
+ }));
374
+ return undefined;
375
+ }
376
+ }, [invoke]);
377
+
378
+ React.useEffect(() => {
379
+ const controller = new AbortController();
380
+ void loadStatus({ signal: controller.signal, restoreProvisioning: true });
381
+ return () => controller.abort();
382
+ }, [loadStatus]);
383
+
384
+ React.useEffect(() => {
385
+ if (model.phase !== 'ready') return undefined;
386
+ const controller = new AbortController();
387
+ let running = false;
388
+ const timer = window.setInterval(async () => {
389
+ if (running || controller.signal.aborted || !mountedRef.current) return;
390
+ running = true;
391
+ await loadStatus({
392
+ signal: controller.signal,
393
+ silent: true,
394
+ restoreProvisioning: false,
395
+ });
396
+ running = false;
397
+ }, 15_000);
398
+ return () => {
399
+ controller.abort();
400
+ window.clearInterval(timer);
401
+ };
402
+ }, [loadStatus, model.phase]);
403
+
404
+ React.useEffect(() => {
405
+ if (!provision || !ACTIVE_PROVISION_STATES.has(provision.status)) return undefined;
406
+ const timer = window.setInterval(() => {
407
+ if (mountedRef.current) setNow(Date.now());
408
+ }, 1_000);
409
+ return () => window.clearInterval(timer);
410
+ }, [provision?.attemptId, provision?.status]);
411
+
412
+ const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
413
+ if (!mountedRef.current) return;
414
+ setCredentialOpen(false);
415
+ setCredentialError(null);
416
+ setBusy(true);
417
+ try {
418
+ if (replace && provision?.attemptId) {
419
+ await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, {
420
+ attemptId: provision.attemptId,
421
+ });
422
+ if (!mountedRef.current) return;
423
+ }
424
+ setProvision({ status: 'starting' });
425
+ const started = normalizeProvisioning(await invoke(
426
+ DINGTALK_ENDPOINTS.beginProvisioning,
427
+ { locale: 'zh-CN' },
428
+ ));
429
+ if (!mountedRef.current) return;
430
+ if (!started.qrCodeDataUrl) {
431
+ throw new Error('钉钉扫码服务没有返回安全的二维码');
432
+ }
433
+ setNow(Date.now());
434
+ setProvision({
435
+ ...started,
436
+ durationMs: Math.max(1, started.expiresAt - Date.now()),
437
+ });
438
+ announce('钉钉二维码已生成,请使用钉钉 App 扫描。');
439
+ } catch (error) {
440
+ if (!mountedRef.current) return;
441
+ setProvision({
442
+ attemptId: provision?.attemptId,
443
+ status: 'failed',
444
+ error: presentError(error),
445
+ });
446
+ } finally {
447
+ if (mountedRef.current) setBusy(false);
448
+ }
449
+ }, [announce, invoke, provision?.attemptId]);
450
+
451
+ const bindCredentials = React.useCallback(async ({ identity, secret }) => {
452
+ if (!mountedRef.current) return;
453
+ setBusy(true);
454
+ setCredentialError(null);
455
+ try {
456
+ const snapshot = normalizeSnapshot(await invoke(
457
+ DINGTALK_ENDPOINTS.bindCredentials,
458
+ { clientId: identity, clientSecret: secret },
459
+ ));
460
+ if (!mountedRef.current) return;
461
+ setModel({
462
+ phase: 'ready',
463
+ bots: snapshot.bots,
464
+ totals: snapshot.totals,
465
+ revision: snapshot.revision,
466
+ error: null,
467
+ });
468
+ setCredentialOpen(false);
469
+ announce('钉钉机器人凭据已绑定。');
470
+ } catch (error) {
471
+ if (mountedRef.current) setCredentialError(presentError(error));
472
+ } finally {
473
+ if (mountedRef.current) setBusy(false);
474
+ }
475
+ }, [announce, invoke]);
476
+
477
+ const cancelProvisioning = React.useCallback(async () => {
478
+ if (!mountedRef.current) return;
479
+ setBusy(true);
480
+ try {
481
+ if (provision?.attemptId && !['failed', 'expired', 'cancelled'].includes(provision.status)) {
482
+ await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
483
+ if (!mountedRef.current) return;
484
+ }
485
+ setProvision(null);
486
+ announce('已取消钉钉机器人接入。');
487
+ focusAddButton();
488
+ } catch (error) {
489
+ if (!mountedRef.current) return;
490
+ setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
491
+ } finally {
492
+ if (mountedRef.current) setBusy(false);
493
+ }
494
+ }, [announce, focusAddButton, invoke, provision?.attemptId, provision?.status]);
495
+
496
+ React.useEffect(() => {
497
+ const attemptId = provision?.attemptId;
498
+ if (!attemptId || !ACTIVE_PROVISION_STATES.has(provision.status)) return undefined;
499
+ const controller = new AbortController();
500
+ let disposed = false;
501
+ let timer = null;
502
+ const canCommit = () => !disposed && !controller.signal.aborted && mountedRef.current;
503
+ const schedule = (delay) => {
504
+ if (!canCommit()) return;
505
+ if (timer !== null) window.clearTimeout(timer);
506
+ timer = window.setTimeout(() => {
507
+ timer = null;
508
+ if (canCommit()) void poll();
509
+ }, delay);
510
+ };
511
+ const poll = async () => {
512
+ try {
513
+ const response = await invoke(
514
+ DINGTALK_ENDPOINTS.pollProvisioning,
515
+ { attemptId },
516
+ controller.signal,
517
+ );
518
+ if (!canCommit()) return;
519
+ const result = normalizeProvisioning(response);
520
+ if (result.status === 'connected') {
521
+ const snapshot = await loadStatus({
522
+ signal: controller.signal,
523
+ silent: true,
524
+ restoreProvisioning: false,
525
+ });
526
+ if (!canCommit()) return;
527
+ const account = result.botId
528
+ ? snapshot?.bots.find((bot) => bot.botId === result.botId)
529
+ : snapshot?.bots.find((bot) => bot.connected);
530
+ if (!account?.connected) {
531
+ setProvision((current) => current?.attemptId === attemptId
532
+ ? { ...current, ...result, status: 'connecting' }
533
+ : current);
534
+ schedule(result.pollIntervalMs);
535
+ return;
536
+ }
537
+ setProvision(null);
538
+ announce(result.alreadyConnected
539
+ ? '这个钉钉机器人已经接入并保持在线。'
540
+ : '钉钉机器人已接入,可以开始发送消息。');
541
+ return;
542
+ }
543
+ if (!canCommit()) return;
544
+ setProvision((current) => current?.attemptId === attemptId
545
+ ? { ...current, ...result, durationMs: current.durationMs }
546
+ : current);
547
+ if (ACTIVE_PROVISION_STATES.has(result.status)) {
548
+ schedule(result.pollIntervalMs);
549
+ }
550
+ } catch (error) {
551
+ if (error?.name === 'AbortError' || !canCommit()) return;
552
+ setProvision((current) => current?.attemptId === attemptId
553
+ ? { ...current, status: 'failed', error: presentError(error) }
554
+ : current);
555
+ }
556
+ };
557
+ schedule(provision.pollIntervalMs ?? 3_000);
558
+ return () => {
559
+ disposed = true;
560
+ controller.abort();
561
+ if (timer !== null) window.clearTimeout(timer);
562
+ timer = null;
563
+ };
564
+ }, [announce, invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
565
+
566
+ const setBotBusy = React.useCallback((botId, operation) => {
567
+ if (!mountedRef.current) return;
568
+ setBusyByBot((current) => {
569
+ const next = { ...current };
570
+ if (operation) next[botId] = operation;
571
+ else delete next[botId];
572
+ return next;
573
+ });
574
+ }, []);
575
+
576
+ const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => {
577
+ if (!mountedRef.current) return undefined;
578
+ setBotBusy(account.botId, operation);
579
+ try {
580
+ await invoke(endpoint, payload);
581
+ if (!mountedRef.current) return undefined;
582
+ const snapshot = await loadStatus({ silent: true, restoreProvisioning: false });
583
+ if (!mountedRef.current) return undefined;
584
+ announce(typeof success === 'function' ? success(snapshot) : success);
585
+ return snapshot;
586
+ } catch (error) {
587
+ if (!mountedRef.current) return undefined;
588
+ announce(`操作失败:${presentError(error).message}`);
589
+ return undefined;
590
+ } finally {
591
+ if (mountedRef.current) setBotBusy(account.botId, null);
592
+ }
593
+ }, [announce, invoke, loadStatus, setBotBusy]);
594
+
595
+ const reconnect = React.useCallback((account) => runBotAction({
596
+ account,
597
+ operation: 'reconnect',
598
+ endpoint: DINGTALK_ENDPOINTS.reconnectBot,
599
+ payload: { botId: account.botId },
600
+ success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected
601
+ ? '钉钉连接检查完成。'
602
+ : '钉钉仍未连接,插件会继续自动重试。',
603
+ }), [runBotAction]);
604
+
605
+ const remove = React.useCallback(async (account) => {
606
+ const snapshot = await runBotAction({
607
+ account,
608
+ operation: 'delete',
609
+ endpoint: DINGTALK_ENDPOINTS.deleteBot,
610
+ payload: { botId: account.botId, confirm: true },
611
+ success: '钉钉机器人及本机凭据已移除。',
612
+ });
613
+ if (snapshot && mountedRef.current) setRemoveTarget(null);
614
+ }, [runBotAction]);
615
+
616
+ let provisionView = null;
617
+ if (provision?.status === 'starting') {
618
+ provisionView = h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' },
619
+ h('div', { className: 'ddt-spinner' }), h('span', null, '正在申请钉钉授权二维码…'));
620
+ } else if (provision?.status === 'pending') {
621
+ provisionView = h(QrPanel, {
622
+ provision,
623
+ now,
624
+ busy,
625
+ onRefresh: () => void startProvisioning({ replace: true }),
626
+ onCancel: () => void cancelProvisioning(),
627
+ });
628
+ } else if (['scanned', 'authorizing', 'creating', 'connecting'].includes(provision?.status)) {
629
+ provisionView = h(ProgressPanel, {
630
+ status: provision.status,
631
+ busy,
632
+ onCancel: () => void cancelProvisioning(),
633
+ });
634
+ } else if (provision && ['failed', 'expired', 'cancelled'].includes(provision.status)) {
635
+ provisionView = h(ProvisionError, {
636
+ provision,
637
+ busy,
638
+ onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
639
+ onClose: () => void cancelProvisioning(),
640
+ });
641
+ }
642
+
643
+ const credentialView = credentialOpen
644
+ ? h(CredentialBindingPanel, {
645
+ channel: '钉钉',
646
+ identityLabel: 'Client ID',
647
+ identityPlaceholder: '填写钉钉应用 Client ID',
648
+ secretLabel: 'Client Secret',
649
+ secretPlaceholder: '填写钉钉应用 Client Secret',
650
+ busy,
651
+ error: credentialError,
652
+ onSubmit: bindCredentials,
653
+ onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
654
+ })
655
+ : null;
656
+
657
+ return h('section', { className: 'ddt-page dim-channelPage', 'aria-label': '钉钉设置' },
658
+ h(Heading, {
659
+ totals: model.totals,
660
+ adding: Boolean(provision),
661
+ busy,
662
+ onAdd: () => void startProvisioning(),
663
+ onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
664
+ credentialOpen,
665
+ addButtonRef,
666
+ }),
667
+ h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
668
+ model.error && model.phase === 'ready'
669
+ ? h('div', { className: 'ddt-statusNotice dim-statusNotice', role: 'alert' }, `状态刷新失败:${model.error.message}`)
670
+ : null,
671
+ model.phase === 'loading'
672
+ ? h(LoadingView)
673
+ : model.phase === 'error'
674
+ ? h('div', { className: 'ddt-card dim-surfaceCard' },
675
+ h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
676
+ h('h3', null, '无法读取钉钉机器人状态'),
677
+ h('p', null, model.error?.message ?? '请稍后重试'),
678
+ h(Button, { onClick: () => void loadStatus() }, '重新读取')))
679
+ : h(React.Fragment, null,
680
+ credentialView,
681
+ provisionView,
682
+ model.bots.length === 0 && !provision && !credentialOpen
683
+ ? h(EmptyView, { busy, onStart: () => void startProvisioning() })
684
+ : null,
685
+ model.bots.length > 0
686
+ ? h(AccountList, {
687
+ bots: model.bots,
688
+ busyByBot,
689
+ removeTarget,
690
+ onReconnect: (account) => void reconnect(account),
691
+ onRequestRemove: (account) => setRemoveTarget(account.botId),
692
+ onConfirmRemove: (account) => void remove(account),
693
+ onCancelRemove: () => setRemoveTarget(null),
694
+ })
695
+ : null));
696
+ }
697
+
698
+ export function apply(ctx) {
699
+ ctx.effect(() => installDingtalkStyles(), 'dingtalk-settings: install client styles');
700
+ const rpcCall = (endpoint, payload, signal) =>
701
+ ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal);
702
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
703
+ name: 'settings.plugins.tab',
704
+ id: 'dingtalk',
705
+ order: 40,
706
+ label: '钉钉',
707
+ inject: () => ({ rpcCall }),
708
+ }, DingtalkSettingsTab));
709
+ }