@xmanrui/dsh-im 4.10.0 → 4.12.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.
- package/PROACTIVE_DELIVERY.en.md +15 -1
- package/PROACTIVE_DELIVERY.md +15 -1
- package/README.en.md +7 -2
- package/README.md +8 -3
- package/lib/client.js +2341 -1315
- package/lib/index.js +250 -245
- package/package.json +4 -3
- package/plugin-src/client/channel-card-meta.js +9 -1
- package/plugin-src/client/channels/dingtalk/api.js +4 -0
- package/plugin-src/client/channels/dingtalk/index.js +41 -7
- package/plugin-src/client/channels/feishu/api.js +4 -0
- package/plugin-src/client/channels/feishu/group-settings.js +581 -0
- package/plugin-src/client/channels/feishu/index.js +64 -233
- package/plugin-src/client/channels/qq/api.js +4 -0
- package/plugin-src/client/channels/qq/index.js +41 -7
- package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
- package/plugin-src/client/channels/shared/token-api.js +4 -0
- package/plugin-src/client/channels/shared/token-channel.js +62 -29
- package/plugin-src/client/channels/wecom/api.js +4 -0
- package/plugin-src/client/channels/wecom/index.js +41 -7
- package/plugin-src/client/channels/weixin/api.js +4 -0
- package/plugin-src/client/channels/weixin/index.js +41 -7
- package/plugin-src/client/channels/whatsapp/api.js +4 -0
- package/plugin-src/client/channels/whatsapp/index.js +40 -7
- package/plugin-src/client/delivery-settings.js +73 -5
- package/plugin-src/client/i18n.js +22 -0
- package/plugin-src/client/model-setting.js +104 -0
- package/plugin-src/client/styles.js +62 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +3 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
- package/plugin-src/host/channels/shared/production.mjs +3 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
- package/plugin-src/host/channels/slack/production.mjs +3 -0
- package/plugin-src/host/channels/slack/rpc.mjs +9 -0
- package/plugin-src/host/channels/wecom/production.mjs +3 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +3 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
- package/plugin-src/host/delivery-adapter.mjs +141 -3
- package/plugin-src/host/delivery-rpc.mjs +12 -0
- package/plugin-src/host/delivery-service.mjs +110 -3
- package/plugin-src/host/delivery-suggestions.mjs +46 -0
- package/plugin-src/host/harness-session-coordinator.mjs +6 -5
- package/plugin-src/host/index.mjs +18 -1
- package/plugin-src/host/session-sync-coordinator.mjs +217 -0
- package/scripts/verify-package.mjs +12 -6
- package/src/channels/shared/bot-workspace-store.mjs +285 -38
- package/src/channels/shared/harness-client.mjs +124 -43
- package/src/channels/shared/i18n-en/wecom.mjs +24 -0
- package/src/channels/shared/model-setting.mjs +110 -0
- package/src/channels/wecom/wecom-bridge.mjs +275 -2
- package/src/channels/wecom/wecom-cards.mjs +101 -0
- package/src/channels/wecom/wecom-runtime.mjs +2 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { h } from '../../i18n.js';
|
|
4
|
+
import {
|
|
5
|
+
FEISHU_ENDPOINTS,
|
|
6
|
+
FEISHU_REGISTRATION_OPERATIONS,
|
|
7
|
+
formatRemaining,
|
|
8
|
+
normalizeBotsSnapshot,
|
|
9
|
+
normalizeGroupResponseMode,
|
|
10
|
+
normalizePollResult,
|
|
11
|
+
normalizeProvisioning,
|
|
12
|
+
presentError,
|
|
13
|
+
unwrapRpcResult,
|
|
14
|
+
} from './api.js';
|
|
15
|
+
|
|
16
|
+
const GROUP_MESSAGE_PERMISSION_OPERATION = FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
|
|
17
|
+
|
|
18
|
+
function SettingsButton({ children, kind = 'secondary', className = '', ...props }) {
|
|
19
|
+
return h('button', {
|
|
20
|
+
...props,
|
|
21
|
+
type: 'button',
|
|
22
|
+
className: `dim-deliveryButton ${className}`.trim(),
|
|
23
|
+
'data-kind': kind,
|
|
24
|
+
}, children);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function groupSettingsFrom(value) {
|
|
28
|
+
return {
|
|
29
|
+
groupResponseMode: normalizeGroupResponseMode(value?.groupResponseMode),
|
|
30
|
+
groupTopicReply: value?.groupTopicReply === true,
|
|
31
|
+
groupMessagePermissionGranted: value?.groupMessagePermissionGranted === true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function targetBotFromSnapshot(value, botId) {
|
|
36
|
+
const snapshot = normalizeBotsSnapshot(value);
|
|
37
|
+
const bot = snapshot.bots.find((entry) => entry.botId === botId);
|
|
38
|
+
if (!bot) throw new Error('飞书服务没有返回当前机器人的设置');
|
|
39
|
+
return { snapshot, bot };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeVerificationHref(value) {
|
|
43
|
+
if (!value) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
return url.protocol === 'https:'
|
|
47
|
+
&& [
|
|
48
|
+
'accounts.feishu.cn',
|
|
49
|
+
'accounts.larksuite.com',
|
|
50
|
+
'open.feishu.cn',
|
|
51
|
+
'open.larksuite.com',
|
|
52
|
+
].includes(url.hostname)
|
|
53
|
+
&& !url.port
|
|
54
|
+
&& !url.username
|
|
55
|
+
&& !url.password
|
|
56
|
+
? url.toString()
|
|
57
|
+
: undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeQrSource(value) {
|
|
64
|
+
if (!value) return undefined;
|
|
65
|
+
return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
|
|
66
|
+
? value
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function QrPlaceholder() {
|
|
71
|
+
return h('svg', {
|
|
72
|
+
viewBox: '0 0 24 24',
|
|
73
|
+
width: 50,
|
|
74
|
+
height: 50,
|
|
75
|
+
fill: 'currentColor',
|
|
76
|
+
'aria-hidden': 'true',
|
|
77
|
+
}, h('path', {
|
|
78
|
+
d: 'M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm9-2h3v3h-3v-3Zm4 0h3v7h-3v-3h-2v3h-2v-5h4v-2Z',
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function GroupResponseModeEditor({
|
|
83
|
+
value,
|
|
84
|
+
permissionGranted = false,
|
|
85
|
+
disabled = false,
|
|
86
|
+
authorizationDisabled = false,
|
|
87
|
+
onSave,
|
|
88
|
+
onAuthorize,
|
|
89
|
+
}) {
|
|
90
|
+
const current = normalizeGroupResponseMode(value);
|
|
91
|
+
const [saving, setSaving] = React.useState(false);
|
|
92
|
+
const [authorizing, setAuthorizing] = React.useState(false);
|
|
93
|
+
const [error, setError] = React.useState(null);
|
|
94
|
+
|
|
95
|
+
const change = async (event) => {
|
|
96
|
+
const next = normalizeGroupResponseMode(event.target.value);
|
|
97
|
+
if (next === current || saving || disabled) return;
|
|
98
|
+
setSaving(true);
|
|
99
|
+
setError(null);
|
|
100
|
+
try {
|
|
101
|
+
await onSave?.(next);
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
setError(cause?.message ?? '群聊响应方式修改失败,请重试。');
|
|
104
|
+
} finally {
|
|
105
|
+
setSaving(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const authorize = async () => {
|
|
110
|
+
if (current !== 'all' || saving || authorizing || disabled || authorizationDisabled) return;
|
|
111
|
+
setAuthorizing(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
try {
|
|
114
|
+
await onAuthorize?.();
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
setError(cause?.message ?? '群消息权限授权失败,请重试。');
|
|
117
|
+
} finally {
|
|
118
|
+
setAuthorizing(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return h('section', {
|
|
123
|
+
className: 'dim-feishuGroupControl',
|
|
124
|
+
'aria-labelledby': 'dim-feishu-group-response-title',
|
|
125
|
+
},
|
|
126
|
+
h('div', { className: 'dim-feishuGroupControlHeader' },
|
|
127
|
+
h('h3', { id: 'dim-feishu-group-response-title' }, '群聊响应方式'),
|
|
128
|
+
saving || authorizing
|
|
129
|
+
? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' },
|
|
130
|
+
saving ? '保存中…' : '正在准备授权…')
|
|
131
|
+
: null),
|
|
132
|
+
h('select', {
|
|
133
|
+
className: 'dim-feishuGroupSelect',
|
|
134
|
+
value: current,
|
|
135
|
+
disabled: disabled || saving,
|
|
136
|
+
'aria-label': '群聊响应方式',
|
|
137
|
+
onChange: (event) => { void change(event); },
|
|
138
|
+
},
|
|
139
|
+
h('option', { value: 'mention' }, '仅在 @机器人时响应(推荐)'),
|
|
140
|
+
h('option', { value: 'all' }, '响应所有群消息')),
|
|
141
|
+
h('p', { className: 'dim-feishuGroupHelp' },
|
|
142
|
+
current === 'mention'
|
|
143
|
+
? permissionGranted
|
|
144
|
+
? '私聊始终响应;群聊仅处理明确 @当前机器人的消息。群消息权限已开通,再次切换无需授权。'
|
|
145
|
+
: '私聊始终响应;群聊仅处理明确 @当前机器人的消息。选择全部消息后会打开飞书官方授权流程。'
|
|
146
|
+
: permissionGranted
|
|
147
|
+
? '已开通“获取群组中所有消息”权限(im:message.group_msg);机器人会处理群聊中的所有可见消息。'
|
|
148
|
+
: '尚未确认“获取群组中所有消息”权限,请完成飞书授权。'),
|
|
149
|
+
current === 'all'
|
|
150
|
+
? h('div', { className: 'dim-feishuGroupPermissionAction' },
|
|
151
|
+
h(SettingsButton, {
|
|
152
|
+
disabled: disabled || authorizationDisabled || saving || authorizing,
|
|
153
|
+
'aria-busy': authorizing ? 'true' : undefined,
|
|
154
|
+
'aria-label': permissionGranted ? '重新授权群消息权限' : '授权群消息权限',
|
|
155
|
+
onClick: () => { void authorize(); },
|
|
156
|
+
}, authorizing ? '正在准备…' : permissionGranted ? '重新授权' : '去授权'))
|
|
157
|
+
: null,
|
|
158
|
+
error ? h('p', {
|
|
159
|
+
className: 'dim-feishuGroupError',
|
|
160
|
+
role: 'alert',
|
|
161
|
+
}, error) : null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function GroupTopicReplyEditor({ value = false, disabled = false, onSave }) {
|
|
165
|
+
const current = value === true ? 'on' : 'off';
|
|
166
|
+
const [saving, setSaving] = React.useState(false);
|
|
167
|
+
const [error, setError] = React.useState(null);
|
|
168
|
+
|
|
169
|
+
const change = async (event) => {
|
|
170
|
+
const next = event.target.value === 'on';
|
|
171
|
+
if ((next ? 'on' : 'off') === current || saving || disabled) return;
|
|
172
|
+
setSaving(true);
|
|
173
|
+
setError(null);
|
|
174
|
+
try {
|
|
175
|
+
await onSave?.(next);
|
|
176
|
+
} catch (cause) {
|
|
177
|
+
setError(cause?.message ?? '群聊以话题方式回复设置保存失败,请重试。');
|
|
178
|
+
} finally {
|
|
179
|
+
setSaving(false);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return h('section', {
|
|
184
|
+
className: 'dim-feishuGroupControl',
|
|
185
|
+
'aria-labelledby': 'dim-feishu-group-topic-title',
|
|
186
|
+
},
|
|
187
|
+
h('div', { className: 'dim-feishuGroupControlHeader' },
|
|
188
|
+
h('h3', { id: 'dim-feishu-group-topic-title' }, '群聊以话题方式回复'),
|
|
189
|
+
saving
|
|
190
|
+
? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
|
|
191
|
+
: null),
|
|
192
|
+
h('select', {
|
|
193
|
+
className: 'dim-feishuGroupSelect',
|
|
194
|
+
value: current,
|
|
195
|
+
disabled: disabled || saving,
|
|
196
|
+
'aria-label': '群聊以话题方式回复',
|
|
197
|
+
onChange: (event) => { void change(event); },
|
|
198
|
+
},
|
|
199
|
+
h('option', { value: 'off' }, '关闭(群内直接回复)'),
|
|
200
|
+
h('option', { value: 'on' }, '开启(自动开启独立飞书话题)')),
|
|
201
|
+
h('p', { className: 'dim-feishuGroupHelp' },
|
|
202
|
+
'开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。'),
|
|
203
|
+
error ? h('p', {
|
|
204
|
+
className: 'dim-feishuGroupError',
|
|
205
|
+
role: 'alert',
|
|
206
|
+
}, error) : null);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function PermissionFlow({ provision, now, busy, botName, onRetry, onCancel, onClose }) {
|
|
210
|
+
if (!provision) return null;
|
|
211
|
+
const phase = provision.phase;
|
|
212
|
+
const error = provision.error;
|
|
213
|
+
|
|
214
|
+
if (phase === 'creating' || phase === 'connecting') {
|
|
215
|
+
return h('section', {
|
|
216
|
+
className: 'dim-feishuGroupAuthorization dim-feishuGroupAuthorizationState',
|
|
217
|
+
'aria-live': 'polite',
|
|
218
|
+
'aria-busy': 'true',
|
|
219
|
+
},
|
|
220
|
+
h('span', { className: 'dim-feishuGroupSpinner', 'aria-hidden': 'true' }),
|
|
221
|
+
h('div', null,
|
|
222
|
+
h('h3', null, phase === 'creating'
|
|
223
|
+
? '正在准备权限授权二维码'
|
|
224
|
+
: '已确认,正在启用全部消息模式'),
|
|
225
|
+
h('p', null, phase === 'creating'
|
|
226
|
+
? '正在为现有飞书应用申请群消息权限二维码,请稍候。'
|
|
227
|
+
: '权限配置已提交,正在保存设置并重连此机器人;此阶段无法取消,其他机器人不会中断。')));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (phase === 'error') {
|
|
231
|
+
return h('section', {
|
|
232
|
+
className: 'dim-feishuGroupAuthorization dim-feishuGroupAuthorizationError',
|
|
233
|
+
role: 'alert',
|
|
234
|
+
},
|
|
235
|
+
h('div', null,
|
|
236
|
+
h('h3', null, '群消息权限没有开通完成'),
|
|
237
|
+
h('p', null, error?.message ?? '群消息权限授权失败,请重试。'),
|
|
238
|
+
error?.code ? h('code', null, error.code) : null),
|
|
239
|
+
h('div', { className: 'dim-feishuGroupAuthorizationActions' },
|
|
240
|
+
h(SettingsButton, { kind: 'primary', onClick: onRetry, disabled: busy },
|
|
241
|
+
busy ? '正在准备…' : '重新生成二维码'),
|
|
242
|
+
h(SettingsButton, { onClick: onClose, disabled: busy }, '关闭')));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const qrSource = safeQrSource(provision.qrCodeDataUrl);
|
|
246
|
+
const href = safeVerificationHref(provision.verificationUrl);
|
|
247
|
+
const remaining = Math.max(0, (provision.expiresAt ?? now) - now);
|
|
248
|
+
const expired = provision.expired === true || remaining === 0;
|
|
249
|
+
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
|
250
|
+
|
|
251
|
+
return h('section', {
|
|
252
|
+
className: 'dim-feishuGroupAuthorization',
|
|
253
|
+
'aria-label': `${botName}的飞书授权流程`,
|
|
254
|
+
},
|
|
255
|
+
h('div', { className: 'dim-feishuGroupQrColumn' },
|
|
256
|
+
h('div', { className: 'dim-feishuGroupQrFrame' },
|
|
257
|
+
qrSource
|
|
258
|
+
? h('img', {
|
|
259
|
+
src: qrSource,
|
|
260
|
+
alt: `用于为${botName}开通群消息权限的一次性授权二维码`,
|
|
261
|
+
})
|
|
262
|
+
: h('div', { className: 'dim-feishuGroupQrFallback' },
|
|
263
|
+
h(QrPlaceholder), h('span', null, '二维码未就绪,请打开授权链接')),
|
|
264
|
+
expired
|
|
265
|
+
? h('div', { className: 'dim-feishuGroupQrExpired', role: 'status' },
|
|
266
|
+
h('span', null, '二维码已失效'), h('small', null, '请刷新后重新扫码'))
|
|
267
|
+
: null),
|
|
268
|
+
h('div', {
|
|
269
|
+
className: 'dim-feishuGroupCountdown',
|
|
270
|
+
'aria-label': expired ? '二维码已失效' : `二维码剩余 ${formatRemaining(remaining)}`,
|
|
271
|
+
},
|
|
272
|
+
h('span', null, expired ? '等待刷新' : '二维码有效时间'),
|
|
273
|
+
h('strong', null, formatRemaining(remaining)),
|
|
274
|
+
h('span', { className: 'dim-feishuGroupProgress', 'aria-hidden': 'true' },
|
|
275
|
+
h('span', { style: { width: `${Math.round(progress * 100)}%` } })))),
|
|
276
|
+
h('div', { className: 'dim-feishuGroupAuthorizationCopy' },
|
|
277
|
+
h('span', { className: 'dim-feishuGroupAuthorizationEyebrow' },
|
|
278
|
+
`正在为「${botName}」开通群消息权限`),
|
|
279
|
+
h('h3', null, expired ? '刷新二维码后继续' : '使用飞书确认群消息权限'),
|
|
280
|
+
h('p', null,
|
|
281
|
+
'扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。'),
|
|
282
|
+
h('ol', null,
|
|
283
|
+
h('li', null, '打开飞书移动端,使用扫一扫读取二维码'),
|
|
284
|
+
h('li', null, '核对现有应用,并确认“获取群组中所有消息”权限'),
|
|
285
|
+
h('li', null, '保持本页打开,等待权限生效并自动切换响应方式')),
|
|
286
|
+
h('div', { className: 'dim-feishuGroupAuthorizationActions' },
|
|
287
|
+
expired
|
|
288
|
+
? h(SettingsButton, { kind: 'primary', onClick: onRetry, disabled: busy },
|
|
289
|
+
busy ? '刷新中…' : '刷新二维码')
|
|
290
|
+
: href
|
|
291
|
+
? h('a', {
|
|
292
|
+
className: 'dim-deliveryButton dim-feishuGroupAuthorizationLink',
|
|
293
|
+
href,
|
|
294
|
+
target: '_blank',
|
|
295
|
+
rel: 'noopener noreferrer',
|
|
296
|
+
}, '在飞书中打开')
|
|
297
|
+
: null,
|
|
298
|
+
!expired
|
|
299
|
+
? h(SettingsButton, { onClick: onRetry, disabled: busy }, '换一个二维码')
|
|
300
|
+
: null,
|
|
301
|
+
h(SettingsButton, { onClick: onCancel, disabled: busy }, '取消授权'))));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function FeishuGroupSettingsPage({ account, rpcCall }) {
|
|
305
|
+
const [settings, setSettings] = React.useState(() => groupSettingsFrom(account));
|
|
306
|
+
const [refreshing, setRefreshing] = React.useState(false);
|
|
307
|
+
const [refreshError, setRefreshError] = React.useState(null);
|
|
308
|
+
const [provision, setProvision] = React.useState(null);
|
|
309
|
+
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
|
310
|
+
const [now, setNow] = React.useState(() => Date.now());
|
|
311
|
+
const mounted = React.useRef(true);
|
|
312
|
+
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
setSettings(groupSettingsFrom(account));
|
|
315
|
+
}, [
|
|
316
|
+
account.botId,
|
|
317
|
+
account.groupResponseMode,
|
|
318
|
+
account.groupTopicReply,
|
|
319
|
+
account.groupMessagePermissionGranted,
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
React.useEffect(() => {
|
|
323
|
+
mounted.current = true;
|
|
324
|
+
return () => { mounted.current = false; };
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
|
328
|
+
if (typeof rpcCall !== 'function') throw new Error('飞书群聊设置暂不可用。');
|
|
329
|
+
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
|
330
|
+
}, [rpcCall]);
|
|
331
|
+
|
|
332
|
+
const applySnapshot = React.useCallback((value) => {
|
|
333
|
+
const result = targetBotFromSnapshot(value, account.botId);
|
|
334
|
+
if (mounted.current) setSettings(groupSettingsFrom(result.bot));
|
|
335
|
+
return result;
|
|
336
|
+
}, [account.botId]);
|
|
337
|
+
|
|
338
|
+
const loadSettings = React.useCallback(async ({ signal, restoreProvisioning = false } = {}) => {
|
|
339
|
+
setRefreshing(true);
|
|
340
|
+
setRefreshError(null);
|
|
341
|
+
try {
|
|
342
|
+
const value = await invoke(FEISHU_ENDPOINTS.status, {}, signal);
|
|
343
|
+
if (signal?.aborted || !mounted.current) return undefined;
|
|
344
|
+
const result = applySnapshot(value);
|
|
345
|
+
if (restoreProvisioning) {
|
|
346
|
+
const active = result.snapshot.provisioning;
|
|
347
|
+
if (active?.operation === GROUP_MESSAGE_PERMISSION_OPERATION
|
|
348
|
+
&& active.botId === account.botId) {
|
|
349
|
+
const timestamp = Date.now();
|
|
350
|
+
setNow(timestamp);
|
|
351
|
+
setProvision({
|
|
352
|
+
phase: active.submitted ? 'connecting' : 'qr',
|
|
353
|
+
...active,
|
|
354
|
+
durationMs: Math.max(1, active.expiresAt - timestamp),
|
|
355
|
+
expired: !active.submitted && active.expiresAt <= timestamp,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return result.bot;
|
|
360
|
+
} catch (cause) {
|
|
361
|
+
if (signal?.aborted || cause?.name === 'AbortError' || !mounted.current) return undefined;
|
|
362
|
+
setRefreshError(presentError(cause));
|
|
363
|
+
return undefined;
|
|
364
|
+
} finally {
|
|
365
|
+
if (!signal?.aborted && mounted.current) setRefreshing(false);
|
|
366
|
+
}
|
|
367
|
+
}, [account.botId, applySnapshot, invoke]);
|
|
368
|
+
|
|
369
|
+
React.useEffect(() => {
|
|
370
|
+
const controller = new AbortController();
|
|
371
|
+
void loadSettings({ signal: controller.signal, restoreProvisioning: true });
|
|
372
|
+
return () => controller.abort();
|
|
373
|
+
}, [loadSettings]);
|
|
374
|
+
|
|
375
|
+
const saveSetting = React.useCallback(async (endpoint, payload) => {
|
|
376
|
+
const value = await invoke(endpoint, { botId: account.botId, ...payload });
|
|
377
|
+
applySnapshot(value);
|
|
378
|
+
}, [account.botId, applySnapshot, invoke]);
|
|
379
|
+
|
|
380
|
+
const startAuthorization = React.useCallback(async ({ replace = false } = {}) => {
|
|
381
|
+
if (provisionBusy) return;
|
|
382
|
+
const previousAttemptId = provision?.attemptId;
|
|
383
|
+
setProvisionBusy(true);
|
|
384
|
+
setProvision({ phase: 'creating', operation: GROUP_MESSAGE_PERMISSION_OPERATION, botId: account.botId });
|
|
385
|
+
try {
|
|
386
|
+
if (replace && previousAttemptId) {
|
|
387
|
+
try {
|
|
388
|
+
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
|
389
|
+
} catch {
|
|
390
|
+
// Starting a new authoritative attempt is safe if the old browser
|
|
391
|
+
// attempt disappeared during a Host restart.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const normalized = normalizeProvisioning(await invoke(
|
|
395
|
+
FEISHU_ENDPOINTS.beginGroupMessagePermission,
|
|
396
|
+
{ botId: account.botId },
|
|
397
|
+
));
|
|
398
|
+
if (normalized.operation !== GROUP_MESSAGE_PERMISSION_OPERATION
|
|
399
|
+
|| normalized.botId !== account.botId) {
|
|
400
|
+
throw new Error('飞书服务返回了不匹配的群消息权限二维码');
|
|
401
|
+
}
|
|
402
|
+
const timestamp = Date.now();
|
|
403
|
+
if (!mounted.current) return;
|
|
404
|
+
setNow(timestamp);
|
|
405
|
+
setProvision({
|
|
406
|
+
phase: normalized.submitted ? 'connecting' : 'qr',
|
|
407
|
+
...normalized,
|
|
408
|
+
durationMs: Math.max(1, normalized.expiresAt - timestamp),
|
|
409
|
+
expired: false,
|
|
410
|
+
});
|
|
411
|
+
} catch (cause) {
|
|
412
|
+
if (!mounted.current) return;
|
|
413
|
+
setProvision({
|
|
414
|
+
phase: 'error',
|
|
415
|
+
operation: GROUP_MESSAGE_PERMISSION_OPERATION,
|
|
416
|
+
botId: account.botId,
|
|
417
|
+
...(previousAttemptId ? { attemptId: previousAttemptId } : {}),
|
|
418
|
+
error: presentError(cause),
|
|
419
|
+
});
|
|
420
|
+
} finally {
|
|
421
|
+
if (mounted.current) setProvisionBusy(false);
|
|
422
|
+
}
|
|
423
|
+
}, [account.botId, invoke, provision?.attemptId, provisionBusy]);
|
|
424
|
+
|
|
425
|
+
const finishAuthorization = React.useCallback(async (signal) => {
|
|
426
|
+
const bot = await loadSettings({ signal, restoreProvisioning: false });
|
|
427
|
+
if (signal?.aborted || !mounted.current) return;
|
|
428
|
+
if (!bot) throw new Error('群消息权限已更新,但暂时无法确认机器人连接状态');
|
|
429
|
+
setProvision(null);
|
|
430
|
+
}, [loadSettings]);
|
|
431
|
+
|
|
432
|
+
React.useEffect(() => {
|
|
433
|
+
if (!provision?.attemptId
|
|
434
|
+
|| !['qr', 'connecting'].includes(provision.phase)
|
|
435
|
+
|| provision.expired) return undefined;
|
|
436
|
+
const timerHost = globalThis.window ?? globalThis;
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const timer = timerHost.setTimeout(async () => {
|
|
439
|
+
try {
|
|
440
|
+
const result = normalizePollResult(await invoke(
|
|
441
|
+
FEISHU_ENDPOINTS.pollProvisioning,
|
|
442
|
+
{ attemptId: provision.attemptId },
|
|
443
|
+
controller.signal,
|
|
444
|
+
));
|
|
445
|
+
if (result.operation !== GROUP_MESSAGE_PERMISSION_OPERATION
|
|
446
|
+
|| result.botId !== account.botId) {
|
|
447
|
+
throw new Error('飞书服务返回了不匹配的注册进度');
|
|
448
|
+
}
|
|
449
|
+
if (result.status === 'connected') {
|
|
450
|
+
await finishAuthorization(controller.signal);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (result.status === 'failed') {
|
|
454
|
+
const error = new Error(result.message ?? '飞书群消息权限开通失败');
|
|
455
|
+
error.code = 'FEISHU_PROVISION_FAILED';
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
if (result.status === 'expired') {
|
|
459
|
+
if (mounted.current) setProvision((current) => current?.attemptId === provision.attemptId
|
|
460
|
+
? { ...current, phase: 'qr', expired: true }
|
|
461
|
+
: current);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (mounted.current) {
|
|
465
|
+
setProvision((current) => current?.attemptId === provision.attemptId
|
|
466
|
+
? {
|
|
467
|
+
...current,
|
|
468
|
+
...(result.provisioning ?? {}),
|
|
469
|
+
phase: ['scanned', 'connecting'].includes(result.status) ? 'connecting' : 'qr',
|
|
470
|
+
}
|
|
471
|
+
: current);
|
|
472
|
+
}
|
|
473
|
+
} catch (cause) {
|
|
474
|
+
if (controller.signal.aborted || cause?.name === 'AbortError' || !mounted.current) return;
|
|
475
|
+
setProvision((current) => current?.attemptId === provision.attemptId
|
|
476
|
+
? { ...current, phase: 'error', error: presentError(cause) }
|
|
477
|
+
: current);
|
|
478
|
+
}
|
|
479
|
+
}, provision.pollIntervalMs ?? 1_800);
|
|
480
|
+
return () => {
|
|
481
|
+
controller.abort();
|
|
482
|
+
timerHost.clearTimeout(timer);
|
|
483
|
+
};
|
|
484
|
+
}, [account.botId, finishAuthorization, invoke, provision]);
|
|
485
|
+
|
|
486
|
+
React.useEffect(() => {
|
|
487
|
+
if (!provision?.attemptId || provision.phase !== 'qr' || provision.expired) return undefined;
|
|
488
|
+
const timerHost = globalThis.window ?? globalThis;
|
|
489
|
+
const tick = () => {
|
|
490
|
+
const timestamp = Date.now();
|
|
491
|
+
setNow(timestamp);
|
|
492
|
+
if (timestamp >= provision.expiresAt) {
|
|
493
|
+
setProvision((current) => current?.attemptId === provision.attemptId
|
|
494
|
+
? { ...current, expired: true }
|
|
495
|
+
: current);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
tick();
|
|
499
|
+
const timer = timerHost.setInterval(tick, 1_000);
|
|
500
|
+
return () => timerHost.clearInterval(timer);
|
|
501
|
+
}, [provision?.attemptId, provision?.expired, provision?.expiresAt, provision?.phase]);
|
|
502
|
+
|
|
503
|
+
const cancelAuthorization = React.useCallback(async () => {
|
|
504
|
+
if (!provision?.attemptId || provisionBusy) {
|
|
505
|
+
setProvision(null);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
setProvisionBusy(true);
|
|
509
|
+
try {
|
|
510
|
+
const result = normalizePollResult(await invoke(
|
|
511
|
+
FEISHU_ENDPOINTS.cancelProvisioning,
|
|
512
|
+
{ attemptId: provision.attemptId },
|
|
513
|
+
));
|
|
514
|
+
if (result.operation !== GROUP_MESSAGE_PERMISSION_OPERATION
|
|
515
|
+
|| result.botId !== account.botId) {
|
|
516
|
+
throw new Error('飞书服务返回了不匹配的注册进度');
|
|
517
|
+
}
|
|
518
|
+
if (result.status === 'connecting') {
|
|
519
|
+
setProvision((current) => ({ ...current, phase: 'connecting', submitted: true }));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (result.status === 'connected') {
|
|
523
|
+
await finishAuthorization();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
setProvision(null);
|
|
527
|
+
await loadSettings({ restoreProvisioning: false });
|
|
528
|
+
} catch (cause) {
|
|
529
|
+
if (mounted.current) {
|
|
530
|
+
setProvision((current) => ({ ...current, phase: 'error', error: presentError(cause) }));
|
|
531
|
+
}
|
|
532
|
+
} finally {
|
|
533
|
+
if (mounted.current) setProvisionBusy(false);
|
|
534
|
+
}
|
|
535
|
+
}, [account.botId, finishAuthorization, invoke, loadSettings, provision, provisionBusy]);
|
|
536
|
+
|
|
537
|
+
const disabled = refreshing || Boolean(provision);
|
|
538
|
+
return h('section', {
|
|
539
|
+
className: 'dim-feishuGroupSettings',
|
|
540
|
+
'aria-label': '群聊设置',
|
|
541
|
+
},
|
|
542
|
+
refreshError ? h('p', {
|
|
543
|
+
className: 'dim-feishuGroupRefreshError',
|
|
544
|
+
role: 'alert',
|
|
545
|
+
}, refreshError.message) : null,
|
|
546
|
+
h('div', { className: 'dim-feishuGroupControls' },
|
|
547
|
+
h(GroupResponseModeEditor, {
|
|
548
|
+
value: settings.groupResponseMode,
|
|
549
|
+
permissionGranted: settings.groupMessagePermissionGranted,
|
|
550
|
+
disabled,
|
|
551
|
+
authorizationDisabled: provisionBusy,
|
|
552
|
+
onSave: async (groupResponseMode) => {
|
|
553
|
+
if (groupResponseMode === 'all' && !settings.groupMessagePermissionGranted) {
|
|
554
|
+
await startAuthorization();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
await saveSetting(FEISHU_ENDPOINTS.setGroupResponseMode, { groupResponseMode });
|
|
558
|
+
},
|
|
559
|
+
onAuthorize: () => startAuthorization(),
|
|
560
|
+
}),
|
|
561
|
+
h(GroupTopicReplyEditor, {
|
|
562
|
+
value: settings.groupTopicReply,
|
|
563
|
+
disabled,
|
|
564
|
+
onSave: (groupTopicReply) => saveSetting(
|
|
565
|
+
FEISHU_ENDPOINTS.setGroupTopicReply,
|
|
566
|
+
{ groupTopicReply },
|
|
567
|
+
),
|
|
568
|
+
})),
|
|
569
|
+
h(PermissionFlow, {
|
|
570
|
+
provision,
|
|
571
|
+
now,
|
|
572
|
+
busy: provisionBusy,
|
|
573
|
+
botName: account.botName || '当前机器人',
|
|
574
|
+
onRetry: () => { void startAuthorization({ replace: true }); },
|
|
575
|
+
onCancel: () => { void cancelAuthorization(); },
|
|
576
|
+
onClose: () => {
|
|
577
|
+
setProvision(null);
|
|
578
|
+
void loadSettings({ restoreProvisioning: true });
|
|
579
|
+
},
|
|
580
|
+
}));
|
|
581
|
+
}
|