@xmanrui/dsh-im 0.18.0 → 1.0.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/README.en.md +12 -1
- package/README.md +12 -1
- package/lib/client.js +341 -37
- package/lib/index.js +162 -156
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +23 -5
- package/plugin-src/client/channels/feishu/index.js +291 -39
- package/plugin-src/client/channels/feishu/styles.js +46 -0
- package/plugin-src/client/i18n.js +50 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +5 -2
- package/plugin-src/host/channels/feishu/production.mjs +7 -2
- package/plugin-src/host/channels/feishu/rpc.mjs +61 -7
- package/plugin-src/host/channels/qq/production.mjs +5 -2
- package/plugin-src/host/channels/shared/production.mjs +5 -2
- package/plugin-src/host/channels/slack/production.mjs +5 -2
- package/plugin-src/host/channels/wecom/production.mjs +5 -2
- package/plugin-src/host/channels/weixin/production.mjs +5 -2
- package/plugin-src/host/channels/whatsapp/production.mjs +5 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +11 -1
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +40 -5
- package/src/channels/feishu/feishu-cards.mjs +4 -0
- package/src/channels/feishu/feishu-runtime.mjs +14 -0
- package/src/channels/feishu/group-message-permission-manager.mjs +71 -0
- package/src/channels/feishu/group-response-mode.mjs +15 -0
- package/src/channels/feishu/multi-bot-controller.mjs +258 -9
- package/src/channels/feishu/plugin-config-store.mjs +3 -0
- package/src/channels/feishu/repair-manager.mjs +17 -3
- package/src/channels/qq/qq-bridge.mjs +11 -1
- package/src/channels/shared/bot-workspace-store.mjs +75 -4
- package/src/channels/shared/preset-command.mjs +305 -0
- package/src/channels/shared/text-harness-bridge.mjs +11 -1
- package/src/channels/telegram/telegram-api.mjs +31 -0
- package/src/channels/telegram/telegram-runtime.mjs +27 -1
- package/src/channels/wecom/wecom-bridge.mjs +11 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +11 -1
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
isModelCommand,
|
|
29
29
|
runModelCommand,
|
|
30
30
|
} from '../shared/model-command.mjs';
|
|
31
|
+
import {
|
|
32
|
+
isPresetCommand,
|
|
33
|
+
runPresetCommand,
|
|
34
|
+
} from '../shared/preset-command.mjs';
|
|
31
35
|
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
|
32
36
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
33
37
|
import {
|
|
@@ -40,6 +44,10 @@ import {
|
|
|
40
44
|
workspaceListCard,
|
|
41
45
|
} from './feishu-cards.mjs';
|
|
42
46
|
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
|
|
47
|
+
import {
|
|
48
|
+
FEISHU_GROUP_RESPONSE_MODES,
|
|
49
|
+
normalizeFeishuGroupResponseMode,
|
|
50
|
+
} from './group-response-mode.mjs';
|
|
43
51
|
|
|
44
52
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
45
53
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
@@ -84,6 +92,10 @@ const HELP_TEXT = [
|
|
|
84
92
|
'/models 按序号列出所有可用模型',
|
|
85
93
|
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
|
86
94
|
'示例:先发 /models,再发 /model 2',
|
|
95
|
+
'/presetlist 按序号列出可用 Agent Preset',
|
|
96
|
+
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
97
|
+
'纯数字 ID:/preset id:<ID>',
|
|
98
|
+
'/preset --default 跟随 Host 默认',
|
|
87
99
|
'/stop 停止当前任务',
|
|
88
100
|
'/steer 补充指令 纠偏当前任务',
|
|
89
101
|
'/status 检查连接状态',
|
|
@@ -249,6 +261,8 @@ export class FeishuHarnessBridge {
|
|
|
249
261
|
#signal;
|
|
250
262
|
#botId;
|
|
251
263
|
#appId;
|
|
264
|
+
#botOpenId;
|
|
265
|
+
#groupResponseMode;
|
|
252
266
|
#repair;
|
|
253
267
|
#repairOwnerOpenIds;
|
|
254
268
|
#repairAttempt = null;
|
|
@@ -275,6 +289,8 @@ export class FeishuHarnessBridge {
|
|
|
275
289
|
allowedSenderOpenIds = new Set(),
|
|
276
290
|
botId,
|
|
277
291
|
appId,
|
|
292
|
+
botOpenId,
|
|
293
|
+
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
|
278
294
|
repair,
|
|
279
295
|
repairOwnerOpenIds,
|
|
280
296
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
@@ -308,6 +324,8 @@ export class FeishuHarnessBridge {
|
|
|
308
324
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
|
309
325
|
this.#botId = nonEmptyString(botId);
|
|
310
326
|
this.#appId = nonEmptyString(appId);
|
|
327
|
+
this.#botOpenId = nonEmptyString(botOpenId);
|
|
328
|
+
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
311
329
|
this.#repair = repair ?? null;
|
|
312
330
|
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
|
|
313
331
|
this.#repairOwnerOpenIds = new Set(
|
|
@@ -327,6 +345,18 @@ export class FeishuHarnessBridge {
|
|
|
327
345
|
}
|
|
328
346
|
}
|
|
329
347
|
|
|
348
|
+
setGroupResponseMode(value) {
|
|
349
|
+
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#isAddressed(event) {
|
|
353
|
+
if (event?.message?.chat_type === 'p2p') return true;
|
|
354
|
+
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
|
355
|
+
if (!this.#botOpenId) return mentions.length > 0;
|
|
356
|
+
return mentions.some((mention) => mention?.id?.open_id === this.#botOpenId
|
|
357
|
+
|| mention?.open_id === this.#botOpenId);
|
|
358
|
+
}
|
|
359
|
+
|
|
330
360
|
accept(event) {
|
|
331
361
|
if (this.#signal?.aborted) return Promise.resolve();
|
|
332
362
|
const messageId = nonEmptyString(event?.message?.message_id);
|
|
@@ -337,6 +367,12 @@ export class FeishuHarnessBridge {
|
|
|
337
367
|
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
|
|
338
368
|
return Promise.resolve();
|
|
339
369
|
}
|
|
370
|
+
const addressed = this.#isAddressed(event);
|
|
371
|
+
if (event?.message?.chat_type !== 'p2p'
|
|
372
|
+
&& this.#groupResponseMode === FEISHU_GROUP_RESPONSE_MODES.MENTION
|
|
373
|
+
&& !addressed) {
|
|
374
|
+
return Promise.resolve();
|
|
375
|
+
}
|
|
340
376
|
if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
|
341
377
|
return Promise.resolve();
|
|
342
378
|
}
|
|
@@ -361,9 +397,9 @@ export class FeishuHarnessBridge {
|
|
|
361
397
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
362
398
|
const commandRunner = isControlCommand(commandText)
|
|
363
399
|
? runControlCommand
|
|
364
|
-
: (isModelCommand(commandText)
|
|
365
|
-
|
|
366
|
-
|
|
400
|
+
: (isModelCommand(commandText)
|
|
401
|
+
? runModelCommand
|
|
402
|
+
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
|
367
403
|
if (commandRunner && addressed) {
|
|
368
404
|
const processing = this.#processFastCommand(
|
|
369
405
|
event,
|
|
@@ -407,8 +443,7 @@ export class FeishuHarnessBridge {
|
|
|
407
443
|
actor: senderOpenId(event),
|
|
408
444
|
messageId,
|
|
409
445
|
text: extractText(event) ?? '',
|
|
410
|
-
addressed
|
|
411
|
-
|| (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0),
|
|
446
|
+
addressed,
|
|
412
447
|
hasPendingQuestion: Boolean(pending),
|
|
413
448
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
414
449
|
? pending.queue
|
|
@@ -189,6 +189,10 @@ export function menuHelpText() {
|
|
|
189
189
|
'/watch ID 或序号 关注会话(完成后推送)',
|
|
190
190
|
'/compact 压缩上下文',
|
|
191
191
|
'/workspace 绝对路径 切换工作区',
|
|
192
|
+
'/presetlist 列出可用 Agent Preset',
|
|
193
|
+
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
194
|
+
'纯数字 ID:/preset id:<ID>',
|
|
195
|
+
'/preset --default 跟随 Host 默认',
|
|
192
196
|
].join('\n');
|
|
193
197
|
}
|
|
194
198
|
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
3
3
|
import { cardActionProbeCard } from './feishu-cards.mjs';
|
|
4
4
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
5
|
+
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
5
6
|
import {
|
|
6
7
|
connectionTestTargetUnavailable,
|
|
7
8
|
sendRememberedConnectionTest,
|
|
@@ -87,6 +88,8 @@ export class FeishuRuntime {
|
|
|
87
88
|
#appId;
|
|
88
89
|
#appSecret;
|
|
89
90
|
#domain;
|
|
91
|
+
#botOpenId;
|
|
92
|
+
#groupResponseMode;
|
|
90
93
|
#ownerOpenIds;
|
|
91
94
|
#harness;
|
|
92
95
|
#state;
|
|
@@ -109,6 +112,8 @@ export class FeishuRuntime {
|
|
|
109
112
|
appId,
|
|
110
113
|
appSecret,
|
|
111
114
|
domain = 'feishu',
|
|
115
|
+
botOpenId,
|
|
116
|
+
groupResponseMode,
|
|
112
117
|
ownerOpenId,
|
|
113
118
|
ownerOpenIds,
|
|
114
119
|
harness,
|
|
@@ -138,6 +143,8 @@ export class FeishuRuntime {
|
|
|
138
143
|
this.#appId = appId;
|
|
139
144
|
this.#appSecret = appSecret;
|
|
140
145
|
this.#domain = domain;
|
|
146
|
+
this.#botOpenId = nonEmptyString(botOpenId);
|
|
147
|
+
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
141
148
|
this.#ownerOpenIds = normalizedOwners;
|
|
142
149
|
this.#harness = harness;
|
|
143
150
|
this.#state = state;
|
|
@@ -153,6 +160,11 @@ export class FeishuRuntime {
|
|
|
153
160
|
return structuredClone(this.#status);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
setGroupResponseMode(value) {
|
|
164
|
+
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
165
|
+
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
async start() {
|
|
157
169
|
if (this.#wsClient && this.#status.ready) return this.status;
|
|
158
170
|
if (this.#starting) return this.#starting;
|
|
@@ -202,6 +214,8 @@ export class FeishuRuntime {
|
|
|
202
214
|
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
|
203
215
|
botId: this.#botId,
|
|
204
216
|
appId: this.#appId,
|
|
217
|
+
botOpenId: this.#botOpenId,
|
|
218
|
+
groupResponseMode: this.#groupResponseMode,
|
|
205
219
|
repair: this.#repair,
|
|
206
220
|
repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
|
|
207
221
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { RegistrationManager } from './registration-manager.mjs';
|
|
2
|
+
import { assertTargetedAppUpdateUrl } from './repair-manager.mjs';
|
|
3
|
+
|
|
4
|
+
export const FEISHU_GROUP_MESSAGE_SCOPE = 'im:message.group_msg';
|
|
5
|
+
export const GROUP_MESSAGE_PERMISSION_OPERATION = 'group_message_permission';
|
|
6
|
+
|
|
7
|
+
function accountsDomain(domain) {
|
|
8
|
+
return domain === 'lark' ? 'accounts.larksuite.com' : 'accounts.feishu.cn';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertGroupMessagePermissionUrl(value, expectedAppId, domain = 'feishu') {
|
|
12
|
+
return assertTargetedAppUpdateUrl(
|
|
13
|
+
value,
|
|
14
|
+
expectedAppId,
|
|
15
|
+
domain,
|
|
16
|
+
'Feishu group message permission update',
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Incrementally grants the one sensitive tenant scope needed by all-message
|
|
22
|
+
* mode to an existing app. The fixed manifest prevents this UI action from
|
|
23
|
+
* creating another app or silently adding unrelated capabilities.
|
|
24
|
+
*/
|
|
25
|
+
export class GroupMessagePermissionManager {
|
|
26
|
+
#manager;
|
|
27
|
+
#appId;
|
|
28
|
+
#domain;
|
|
29
|
+
|
|
30
|
+
constructor({ registerApp, onCredentials, appId, domain = 'feishu' } = {}) {
|
|
31
|
+
if (typeof registerApp !== 'function') throw new TypeError('registerApp is required');
|
|
32
|
+
if (typeof onCredentials !== 'function') throw new TypeError('onCredentials is required');
|
|
33
|
+
if (typeof appId !== 'string' || !appId.trim()) throw new TypeError('appId is required');
|
|
34
|
+
if (domain !== 'feishu' && domain !== 'lark') throw new TypeError('domain is invalid');
|
|
35
|
+
|
|
36
|
+
this.#appId = appId.trim();
|
|
37
|
+
this.#domain = domain;
|
|
38
|
+
this.#manager = new RegistrationManager({
|
|
39
|
+
registerApp: (options) => registerApp({
|
|
40
|
+
...options,
|
|
41
|
+
onQRCodeReady: (info) => {
|
|
42
|
+
assertGroupMessagePermissionUrl(info?.url, this.#appId, this.#domain);
|
|
43
|
+
options.onQRCodeReady(info);
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
onCredentials,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
start() {
|
|
51
|
+
return this.#manager.start({
|
|
52
|
+
source: 'deepseek-harness-group-message-permission',
|
|
53
|
+
domain: accountsDomain(this.#domain),
|
|
54
|
+
appId: this.#appId,
|
|
55
|
+
addons: {
|
|
56
|
+
preset: false,
|
|
57
|
+
scopes: { tenant: [FEISHU_GROUP_MESSAGE_SCOPE] },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
status() {
|
|
63
|
+
return this.#manager.status();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
cancel() {
|
|
67
|
+
return this.#manager.cancel();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default GroupMessagePermissionManager;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const FEISHU_GROUP_RESPONSE_MODES = Object.freeze({
|
|
2
|
+
MENTION: 'mention',
|
|
3
|
+
ALL: 'all',
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
export function normalizeFeishuGroupResponseMode(value) {
|
|
7
|
+
return value === FEISHU_GROUP_RESPONSE_MODES.ALL
|
|
8
|
+
? FEISHU_GROUP_RESPONSE_MODES.ALL
|
|
9
|
+
: FEISHU_GROUP_RESPONSE_MODES.MENTION;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isFeishuGroupResponseMode(value) {
|
|
13
|
+
return value === FEISHU_GROUP_RESPONSE_MODES.MENTION
|
|
14
|
+
|| value === FEISHU_GROUP_RESPONSE_MODES.ALL;
|
|
15
|
+
}
|
|
@@ -5,12 +5,24 @@ import {
|
|
|
5
5
|
CALLBACK_REPAIR_OPERATION,
|
|
6
6
|
CallbackRepairManager,
|
|
7
7
|
} from './repair-manager.mjs';
|
|
8
|
+
import {
|
|
9
|
+
GROUP_MESSAGE_PERMISSION_OPERATION,
|
|
10
|
+
GroupMessagePermissionManager,
|
|
11
|
+
} from './group-message-permission-manager.mjs';
|
|
8
12
|
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
|
13
|
+
import {
|
|
14
|
+
isFeishuGroupResponseMode,
|
|
15
|
+
normalizeFeishuGroupResponseMode,
|
|
16
|
+
} from './group-response-mode.mjs';
|
|
9
17
|
|
|
10
18
|
const ACTIVE_REGISTRATION_STATES = new Set([
|
|
11
19
|
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
|
12
20
|
]);
|
|
13
21
|
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
|
22
|
+
const TARGETED_APP_UPDATE_OPERATIONS = new Set([
|
|
23
|
+
CALLBACK_REPAIR_OPERATION,
|
|
24
|
+
GROUP_MESSAGE_PERMISSION_OPERATION,
|
|
25
|
+
]);
|
|
14
26
|
const ALL_VISIBLE_SENDERS = '*';
|
|
15
27
|
const DEFAULT_CALLBACK_PROBE_TIMEOUT_MS = 120_000;
|
|
16
28
|
const MAX_CALLBACK_PROBE_TIMEOUT_MS = 600_000;
|
|
@@ -76,6 +88,8 @@ function configuredBotFingerprint(config) {
|
|
|
76
88
|
botName: config.botName,
|
|
77
89
|
botOpenId: config.botOpenId,
|
|
78
90
|
activated: config.activated,
|
|
91
|
+
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
92
|
+
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
79
93
|
deletionPending: config.deletionPending === true,
|
|
80
94
|
connectedAt: config.connectedAt ?? null,
|
|
81
95
|
createdAt: config.createdAt ?? null,
|
|
@@ -103,7 +117,7 @@ export class MultiBotDshFeishuController {
|
|
|
103
117
|
#runtimes = new Map();
|
|
104
118
|
#botErrors = new Map();
|
|
105
119
|
#registrations = new Map();
|
|
106
|
-
#
|
|
120
|
+
#activeAppUpdates = new Map();
|
|
107
121
|
#botOwnership = new Map();
|
|
108
122
|
#latestRegistrationId = null;
|
|
109
123
|
#configTransition = Promise.resolve();
|
|
@@ -234,12 +248,15 @@ export class MultiBotDshFeishuController {
|
|
|
234
248
|
const target = this.#requireBot(botId);
|
|
235
249
|
if (target.deletionPending) throw new Error('Cannot repair a Feishu bot pending deletion');
|
|
236
250
|
|
|
237
|
-
const activeId = this.#
|
|
251
|
+
const activeId = this.#activeAppUpdates.get(botId);
|
|
238
252
|
const active = activeId ? this.#registrations.get(activeId) : null;
|
|
239
253
|
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
|
254
|
+
if (active.operation !== CALLBACK_REPAIR_OPERATION) {
|
|
255
|
+
throw new Error('Another Feishu app update is already active for this bot');
|
|
256
|
+
}
|
|
240
257
|
return this.registrationStatus(active.id);
|
|
241
258
|
}
|
|
242
|
-
this.#
|
|
259
|
+
this.#activeAppUpdates.delete(botId);
|
|
243
260
|
|
|
244
261
|
const id = this.#createRegistrationId();
|
|
245
262
|
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
|
@@ -278,7 +295,66 @@ export class MultiBotDshFeishuController {
|
|
|
278
295
|
},
|
|
279
296
|
});
|
|
280
297
|
this.#registrations.set(id, record);
|
|
281
|
-
this.#
|
|
298
|
+
this.#activeAppUpdates.set(botId, id);
|
|
299
|
+
this.#latestRegistrationId = id;
|
|
300
|
+
this.#trimRegistrations();
|
|
301
|
+
record.manager.start();
|
|
302
|
+
this.#touch();
|
|
303
|
+
return this.registrationStatus(id);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
startGroupMessagePermission(botId) {
|
|
307
|
+
this.#assertOpen();
|
|
308
|
+
const target = this.#requireBot(botId);
|
|
309
|
+
if (target.deletionPending) {
|
|
310
|
+
throw new Error('Cannot update permissions for a Feishu bot pending deletion');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const activeId = this.#activeAppUpdates.get(botId);
|
|
314
|
+
const active = activeId ? this.#registrations.get(activeId) : null;
|
|
315
|
+
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
|
316
|
+
if (active.operation !== GROUP_MESSAGE_PERMISSION_OPERATION) {
|
|
317
|
+
throw new Error('Another Feishu app update is already active for this bot');
|
|
318
|
+
}
|
|
319
|
+
return this.registrationStatus(active.id);
|
|
320
|
+
}
|
|
321
|
+
this.#activeAppUpdates.delete(botId);
|
|
322
|
+
|
|
323
|
+
const id = this.#createRegistrationId();
|
|
324
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
|
325
|
+
throw new Error('Registration id generator returned an invalid or duplicate id');
|
|
326
|
+
}
|
|
327
|
+
const record = {
|
|
328
|
+
id,
|
|
329
|
+
operation: GROUP_MESSAGE_PERMISSION_OPERATION,
|
|
330
|
+
manager: null,
|
|
331
|
+
botId,
|
|
332
|
+
createdNew: false,
|
|
333
|
+
cancelled: false,
|
|
334
|
+
remoteCommitted: false,
|
|
335
|
+
processing: null,
|
|
336
|
+
publicError: null,
|
|
337
|
+
stage: 'authorizing',
|
|
338
|
+
target: structuredClone(target),
|
|
339
|
+
targetFingerprint: configuredBotFingerprint(target),
|
|
340
|
+
initiator: { actorOpenId: null, chatId: null },
|
|
341
|
+
};
|
|
342
|
+
record.manager = new GroupMessagePermissionManager({
|
|
343
|
+
registerApp: this.#registerApp,
|
|
344
|
+
appId: target.appId,
|
|
345
|
+
domain: target.domain,
|
|
346
|
+
onCredentials: (result) => {
|
|
347
|
+
record.remoteCommitted = true;
|
|
348
|
+
const processing = this.#acceptGroupMessagePermission(record, result);
|
|
349
|
+
const tracked = processing.finally(() => {
|
|
350
|
+
if (record.processing === tracked) record.processing = null;
|
|
351
|
+
});
|
|
352
|
+
record.processing = tracked;
|
|
353
|
+
return tracked;
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
this.#registrations.set(id, record);
|
|
357
|
+
this.#activeAppUpdates.set(botId, id);
|
|
282
358
|
this.#latestRegistrationId = id;
|
|
283
359
|
this.#trimRegistrations();
|
|
284
360
|
record.manager.start();
|
|
@@ -303,7 +379,7 @@ export class MultiBotDshFeishuController {
|
|
|
303
379
|
// Once registerApp has returned, the platform-side update has committed.
|
|
304
380
|
// A repair must finish converging the returned credential and callback
|
|
305
381
|
// probe; cancelling here cannot roll that remote mutation back.
|
|
306
|
-
if (record.operation
|
|
382
|
+
if (TARGETED_APP_UPDATE_OPERATIONS.has(record.operation) && state === 'saving') {
|
|
307
383
|
return this.registrationStatus(attemptId);
|
|
308
384
|
}
|
|
309
385
|
if (!MUTABLE_REGISTRATION_STATES.has(state)) {
|
|
@@ -473,6 +549,25 @@ export class MultiBotDshFeishuController {
|
|
|
473
549
|
});
|
|
474
550
|
}
|
|
475
551
|
|
|
552
|
+
async updateGroupResponseMode(botId, groupResponseMode) {
|
|
553
|
+
this.#assertOpen();
|
|
554
|
+
if (!isFeishuGroupResponseMode(groupResponseMode)) {
|
|
555
|
+
throw new TypeError('Invalid Feishu group response mode');
|
|
556
|
+
}
|
|
557
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
558
|
+
const config = this.#requireBot(botId);
|
|
559
|
+
if (groupResponseMode === 'all' && config.groupMessagePermissionGranted !== true) {
|
|
560
|
+
const error = new Error('Authorize im:message.group_msg before enabling all group messages');
|
|
561
|
+
error.code = 'group_message_permission_required';
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
const saved = await this.#configStore.saveBot({ ...config, groupResponseMode });
|
|
565
|
+
this.#runtimes.get(botId)?.setGroupResponseMode?.(saved.groupResponseMode);
|
|
566
|
+
this.#touch();
|
|
567
|
+
return this.status(botId);
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
|
|
476
571
|
async deleteBot(botId) {
|
|
477
572
|
this.#assertOpen();
|
|
478
573
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
@@ -497,14 +592,14 @@ export class MultiBotDshFeishuController {
|
|
|
497
592
|
async close() {
|
|
498
593
|
if (this.#closed) return;
|
|
499
594
|
this.#closed = true;
|
|
500
|
-
const
|
|
595
|
+
const appUpdateProcessing = [];
|
|
501
596
|
for (const record of this.#registrations.values()) {
|
|
502
597
|
const state = record.manager.status().state;
|
|
503
|
-
if (record.operation
|
|
598
|
+
if (TARGETED_APP_UPDATE_OPERATIONS.has(record.operation) && state === 'saving') {
|
|
504
599
|
// Stop projecting an in-flight repair, but do not request its local
|
|
505
600
|
// credential rollback after the remote update has committed.
|
|
506
601
|
record.manager.cancel();
|
|
507
|
-
if (record.processing)
|
|
602
|
+
if (record.processing) appUpdateProcessing.push(record.processing);
|
|
508
603
|
} else if (MUTABLE_REGISTRATION_STATES.has(state)) {
|
|
509
604
|
record.cancelled = true;
|
|
510
605
|
record.manager.cancel();
|
|
@@ -513,7 +608,7 @@ export class MultiBotDshFeishuController {
|
|
|
513
608
|
await this.#configTransition;
|
|
514
609
|
await Promise.allSettled([...this.#botTransitions.values()]);
|
|
515
610
|
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
|
516
|
-
await Promise.allSettled(
|
|
611
|
+
await Promise.allSettled(appUpdateProcessing);
|
|
517
612
|
// A committed repair can be between SDK completion and its serialized
|
|
518
613
|
// credential/runtime transition when close begins. Waiting for the repair
|
|
519
614
|
// can therefore create a replacement runtime after the first drain. Drain
|
|
@@ -534,6 +629,8 @@ export class MultiBotDshFeishuController {
|
|
|
534
629
|
phase: botPhase({ connected, error, connection }),
|
|
535
630
|
connected,
|
|
536
631
|
configured: true,
|
|
632
|
+
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
633
|
+
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
537
634
|
bot: publicBot(config),
|
|
538
635
|
connection,
|
|
539
636
|
error,
|
|
@@ -585,6 +682,158 @@ export class MultiBotDshFeishuController {
|
|
|
585
682
|
};
|
|
586
683
|
}
|
|
587
684
|
|
|
685
|
+
async #acceptGroupMessagePermission(record, result) {
|
|
686
|
+
const appId = result.client_id;
|
|
687
|
+
const appSecret = result.client_secret;
|
|
688
|
+
const ownerOpenId = optionalNonEmptyString(result.user_info?.open_id);
|
|
689
|
+
const tenantBrand = result.user_info?.tenant_brand;
|
|
690
|
+
const target = record.target;
|
|
691
|
+
|
|
692
|
+
if (record.cancelled) {
|
|
693
|
+
throw this.#callbackRepairError(
|
|
694
|
+
record,
|
|
695
|
+
'abort',
|
|
696
|
+
'Group message permission update was cancelled before local activation.',
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (appId !== target.appId) {
|
|
700
|
+
throw this.#callbackRepairError(
|
|
701
|
+
record,
|
|
702
|
+
'repair_app_mismatch',
|
|
703
|
+
'Feishu returned credentials for a different app.',
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (!ownerOpenId) {
|
|
707
|
+
throw this.#callbackRepairError(
|
|
708
|
+
record,
|
|
709
|
+
'repair_owner_missing',
|
|
710
|
+
'Feishu returned no permission operator identity.',
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (tenantBrand !== undefined && tenantBrand !== target.domain) {
|
|
714
|
+
throw this.#callbackRepairError(
|
|
715
|
+
record,
|
|
716
|
+
'repair_domain_mismatch',
|
|
717
|
+
'Feishu returned credentials for a different account domain.',
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
|
|
721
|
+
&& !target.ownerOpenIds.includes(ownerOpenId)) {
|
|
722
|
+
throw this.#callbackRepairError(
|
|
723
|
+
record,
|
|
724
|
+
'repair_owner_mismatch',
|
|
725
|
+
'The Feishu permission operator is not an owner of this configured bot.',
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
record.stage = 'verifying_identity';
|
|
730
|
+
let verified;
|
|
731
|
+
try {
|
|
732
|
+
verified = await this.#verifyApp({ appId, appSecret, domain: target.domain });
|
|
733
|
+
} catch (error) {
|
|
734
|
+
throw this.#callbackRepairError(
|
|
735
|
+
record,
|
|
736
|
+
'repair_credentials_invalid',
|
|
737
|
+
'Feishu could not verify the updated app credentials.',
|
|
738
|
+
error,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (target.botOpenId && verified?.openId !== target.botOpenId) {
|
|
742
|
+
throw this.#callbackRepairError(
|
|
743
|
+
record,
|
|
744
|
+
'repair_bot_mismatch',
|
|
745
|
+
'The updated Feishu app belongs to a different bot identity.',
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
await this.#serializeConfig(() => this.#withBotTransition(record.botId, async () => {
|
|
750
|
+
const current = this.#configStore.getBot(record.botId);
|
|
751
|
+
if (!current
|
|
752
|
+
|| current.deletionPending
|
|
753
|
+
|| configuredBotFingerprint(current) !== record.targetFingerprint) {
|
|
754
|
+
throw this.#callbackRepairError(
|
|
755
|
+
record,
|
|
756
|
+
'repair_target_changed',
|
|
757
|
+
'The Feishu bot changed while its permission update was in progress.',
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
let previous;
|
|
762
|
+
try {
|
|
763
|
+
previous = await this.#credentials.resolve(current.secretRef);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
throw this.#callbackRepairError(
|
|
766
|
+
record,
|
|
767
|
+
'credential_update_failed',
|
|
768
|
+
'Unable to read the current Feishu credential.',
|
|
769
|
+
error,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (previous?.value !== appSecret) {
|
|
773
|
+
record.stage = 'persisting_secret';
|
|
774
|
+
try {
|
|
775
|
+
await this.#credentials.set(current.secretRef, appSecret);
|
|
776
|
+
} catch (writeError) {
|
|
777
|
+
const observed = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
|
778
|
+
if (observed?.value !== appSecret) {
|
|
779
|
+
throw this.#callbackRepairError(
|
|
780
|
+
record,
|
|
781
|
+
'credential_update_failed',
|
|
782
|
+
'Unable to store the updated Feishu credential.',
|
|
783
|
+
writeError,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const persisted = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
|
788
|
+
if (persisted?.value !== appSecret) {
|
|
789
|
+
throw this.#callbackRepairError(
|
|
790
|
+
record,
|
|
791
|
+
'credential_state_unknown',
|
|
792
|
+
'The updated Feishu credential could not be confirmed after writing.',
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
record.stage = 'enabling_all_messages';
|
|
798
|
+
let saved;
|
|
799
|
+
try {
|
|
800
|
+
saved = await this.#configStore.saveBot({
|
|
801
|
+
...current,
|
|
802
|
+
groupMessagePermissionGranted: true,
|
|
803
|
+
groupResponseMode: 'all',
|
|
804
|
+
});
|
|
805
|
+
} catch (error) {
|
|
806
|
+
throw this.#callbackRepairError(
|
|
807
|
+
record,
|
|
808
|
+
'group_message_permission_save_failed',
|
|
809
|
+
'The permission was accepted, but all-message mode could not be saved.',
|
|
810
|
+
error,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
record.stage = 'restarting';
|
|
815
|
+
try {
|
|
816
|
+
await this.#startRuntime(saved, appSecret);
|
|
817
|
+
} catch (error) {
|
|
818
|
+
this.#botErrors.set(record.botId, {
|
|
819
|
+
code: 'connection_failed',
|
|
820
|
+
message: '群消息权限已开通,但机器人长连接未就绪,请点击重试。',
|
|
821
|
+
});
|
|
822
|
+
this.#touch();
|
|
823
|
+
throw this.#callbackRepairError(
|
|
824
|
+
record,
|
|
825
|
+
'group_message_permission_connection_failed',
|
|
826
|
+
'The permission was accepted, but the Feishu runtime could not restart.',
|
|
827
|
+
error,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
this.#botErrors.delete(record.botId);
|
|
831
|
+
record.stage = 'verified';
|
|
832
|
+
record.publicError = null;
|
|
833
|
+
this.#touch();
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
|
|
588
837
|
async #acceptCallbackRepair(record, result) {
|
|
589
838
|
const appId = result.client_id;
|
|
590
839
|
const appSecret = result.client_secret;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
+
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
4
5
|
|
|
5
6
|
export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|
6
7
|
|
|
@@ -43,6 +44,8 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
43
44
|
botName: cleanString(value.botName),
|
|
44
45
|
botOpenId: cleanString(value.botOpenId),
|
|
45
46
|
activated: value.activated ?? null,
|
|
47
|
+
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
48
|
+
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
46
49
|
deletionPending: value.deletionPending === true,
|
|
47
50
|
connectedAt: cleanString(value.connectedAt),
|
|
48
51
|
createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),
|
|
@@ -17,12 +17,17 @@ function launcherDomain(domain) {
|
|
|
17
17
|
* never fall back to the create-only flow. This catches regressions such as a
|
|
18
18
|
* literal `{{client_id}}` before the broken URL reaches the browser.
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
20
|
+
export function assertTargetedAppUpdateUrl(
|
|
21
|
+
value,
|
|
22
|
+
expectedAppId,
|
|
23
|
+
domain = 'feishu',
|
|
24
|
+
operationLabel = 'Feishu app update',
|
|
25
|
+
) {
|
|
21
26
|
let url;
|
|
22
27
|
try {
|
|
23
28
|
url = new URL(value);
|
|
24
29
|
} catch {
|
|
25
|
-
throw new Error(
|
|
30
|
+
throw new Error(`${operationLabel} returned an invalid verification URL`);
|
|
26
31
|
}
|
|
27
32
|
const supportedDomain = domain === 'feishu' || domain === 'lark';
|
|
28
33
|
const clientIds = url.searchParams.getAll('clientID');
|
|
@@ -49,11 +54,20 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
|
|
|
49
54
|
|| addons.length !== 1
|
|
50
55
|
|| !addons[0]?.trim()
|
|
51
56
|
|| hasPlaceholder) {
|
|
52
|
-
throw new Error(
|
|
57
|
+
throw new Error(`${operationLabel} returned an unsafe verification URL`);
|
|
53
58
|
}
|
|
54
59
|
return url.toString();
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
|
|
63
|
+
return assertTargetedAppUpdateUrl(
|
|
64
|
+
value,
|
|
65
|
+
expectedAppId,
|
|
66
|
+
domain,
|
|
67
|
+
'Feishu callback repair',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
/**
|
|
58
72
|
* One targeted update attempt for an existing Feishu app. It intentionally
|
|
59
73
|
* shares RegistrationManager's polling/state implementation while fixing the
|