@xmanrui/dsh-im 0.17.1 → 0.19.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 (56) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/lib/client.js +1297 -600
  4. package/lib/index.js +190 -145
  5. package/package.json +1 -1
  6. package/plugin-src/client/agent-preset.js +110 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +5 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +48 -2
  9. package/plugin-src/client/channels/feishu/api.js +28 -5
  10. package/plugin-src/client/channels/feishu/index.js +330 -41
  11. package/plugin-src/client/channels/feishu/styles.js +46 -0
  12. package/plugin-src/client/channels/qq/api.js +5 -0
  13. package/plugin-src/client/channels/qq/index.js +37 -6
  14. package/plugin-src/client/channels/shared/token-api.js +5 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +34 -6
  16. package/plugin-src/client/channels/wecom/api.js +5 -0
  17. package/plugin-src/client/channels/wecom/index.js +37 -6
  18. package/plugin-src/client/channels/weixin/api.js +5 -0
  19. package/plugin-src/client/channels/weixin/index.js +50 -4
  20. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  21. package/plugin-src/client/channels/whatsapp/index.js +30 -4
  22. package/plugin-src/client/i18n.js +59 -0
  23. package/plugin-src/client/styles.js +8 -0
  24. package/plugin-src/host/channels/dingtalk/production.mjs +10 -4
  25. package/plugin-src/host/channels/dingtalk/rpc.mjs +12 -0
  26. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +1 -1
  27. package/plugin-src/host/channels/feishu/production.mjs +8 -3
  28. package/plugin-src/host/channels/feishu/rpc.mjs +78 -7
  29. package/plugin-src/host/channels/qq/production.mjs +10 -4
  30. package/plugin-src/host/channels/qq/rpc.mjs +12 -0
  31. package/plugin-src/host/channels/shared/agent-preset-rpc.mjs +25 -0
  32. package/plugin-src/host/channels/shared/production.mjs +10 -4
  33. package/plugin-src/host/channels/shared/rpc.mjs +12 -0
  34. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  35. package/plugin-src/host/channels/slack/production.mjs +10 -4
  36. package/plugin-src/host/channels/slack/rpc.mjs +13 -0
  37. package/plugin-src/host/channels/wecom/production.mjs +10 -4
  38. package/plugin-src/host/channels/wecom/rpc.mjs +12 -0
  39. package/plugin-src/host/channels/weixin/production.mjs +10 -4
  40. package/plugin-src/host/channels/weixin/rpc.mjs +15 -0
  41. package/plugin-src/host/channels/whatsapp/production.mjs +10 -4
  42. package/plugin-src/host/channels/whatsapp/rpc.mjs +12 -0
  43. package/src/channels/discord/discord-api.mjs +1 -1
  44. package/src/channels/feishu/bridge.mjs +29 -4
  45. package/src/channels/feishu/feishu-runtime.mjs +14 -0
  46. package/src/channels/feishu/group-message-permission-manager.mjs +71 -0
  47. package/src/channels/feishu/group-response-mode.mjs +15 -0
  48. package/src/channels/feishu/multi-bot-controller.mjs +258 -9
  49. package/src/channels/feishu/plugin-config-store.mjs +3 -0
  50. package/src/channels/feishu/repair-manager.mjs +17 -3
  51. package/src/channels/shared/agent-preset.mjs +74 -0
  52. package/src/channels/shared/bot-workspace-store.mjs +141 -14
  53. package/src/channels/shared/harness-client.mjs +6 -4
  54. package/src/channels/telegram/telegram-api.mjs +31 -0
  55. package/src/channels/telegram/telegram-runtime.mjs +25 -1
  56. package/src/channels/weixin/weixin-api.mjs +1 -1
@@ -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
- #activeRepairs = new Map();
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.#activeRepairs.get(botId);
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.#activeRepairs.delete(botId);
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.#activeRepairs.set(botId, id);
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 === CALLBACK_REPAIR_OPERATION && state === 'saving') {
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 repairProcessing = [];
595
+ const appUpdateProcessing = [];
501
596
  for (const record of this.#registrations.values()) {
502
597
  const state = record.manager.status().state;
503
- if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
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) repairProcessing.push(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(repairProcessing);
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 assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
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('Feishu callback repair returned an invalid verification URL');
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('Feishu callback repair returned an unsafe verification URL');
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
@@ -0,0 +1,74 @@
1
+ /** Matches DeepSeek Harness agent-preset directory ids. */
2
+ export const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
3
+
4
+ export const EMPTY_AGENT_PRESET_CATALOG = Object.freeze({
5
+ defaultId: '',
6
+ items: Object.freeze([]),
7
+ });
8
+
9
+ export function normalizeAgentPresetId(value) {
10
+ if (value == null) return null;
11
+ if (typeof value !== 'string') return null;
12
+ const id = value.trim();
13
+ return AGENT_PRESET_ID.test(id) ? id : null;
14
+ }
15
+
16
+ export function validateAgentPresetId(value) {
17
+ if (value == null || value === '') return null;
18
+ const id = normalizeAgentPresetId(value);
19
+ if (!id) {
20
+ const error = new Error('Agent Preset 无效。');
21
+ error.code = 'agent-preset-invalid';
22
+ throw error;
23
+ }
24
+ return id;
25
+ }
26
+
27
+ function catalogItem(value) {
28
+ if (typeof value === 'string') {
29
+ const id = normalizeAgentPresetId(value);
30
+ return id ? { id, label: id } : null;
31
+ }
32
+ if (!value || typeof value !== 'object') return null;
33
+ if (value.broken !== undefined) return null;
34
+ const id = normalizeAgentPresetId(value.id);
35
+ if (!id) return null;
36
+ const label = typeof value.name === 'string' && value.name.trim()
37
+ ? value.name.trim().slice(0, 128)
38
+ : typeof value.label === 'string' && value.label.trim()
39
+ ? value.label.trim().slice(0, 128)
40
+ : id;
41
+ return { id, label };
42
+ }
43
+
44
+ export function normalizeAgentPresetCatalog(value) {
45
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
46
+ return { defaultId: '', items: [] };
47
+ }
48
+ const items = [];
49
+ const seen = new Set();
50
+ for (const entry of Array.isArray(value.items) ? value.items : []) {
51
+ const item = catalogItem(entry);
52
+ if (!item || seen.has(item.id)) continue;
53
+ seen.add(item.id);
54
+ items.push(item);
55
+ }
56
+ return {
57
+ defaultId: normalizeAgentPresetId(value.defaultId) ?? '',
58
+ items,
59
+ };
60
+ }
61
+
62
+ export async function listAgentPresetCatalog(ctx) {
63
+ try {
64
+ const service = typeof ctx?.get === 'function' ? ctx.get('agentPresets') : ctx?.agentPresets;
65
+ if (!service || typeof service.list !== 'function') return { defaultId: '', items: [] };
66
+ const listed = await service.list();
67
+ return normalizeAgentPresetCatalog({
68
+ defaultId: typeof service.defaultId === 'string' ? service.defaultId : '',
69
+ items: Array.isArray(listed) ? listed : [],
70
+ });
71
+ } catch {
72
+ return { defaultId: '', items: [] };
73
+ }
74
+ }