@xmanrui/dsh-im 0.14.0 → 0.16.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/lib/client.js +246 -46
- package/lib/index.js +137 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +23 -1
- package/plugin-src/client/channels/feishu/index.js +235 -46
- package/plugin-src/client/channels/feishu/styles.js +2 -0
- package/plugin-src/client/i18n.js +39 -0
- package/plugin-src/client/index.js +2 -1
- package/plugin-src/client/styles.js +2 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +146 -12
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +723 -1
- package/src/channels/feishu/feishu-cards.mjs +155 -0
- package/src/channels/feishu/feishu-runtime.mjs +198 -0
- package/src/channels/feishu/multi-bot-controller.mjs +357 -2
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +109 -0
- package/src/channels/shared/workspace-command.mjs +2 -2
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-controller.mjs +56 -12
- package/src/channels/weixin/weixin-runtime.mjs +12 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
3
3
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
4
|
+
import {
|
|
5
|
+
CALLBACK_REPAIR_OPERATION,
|
|
6
|
+
CallbackRepairManager,
|
|
7
|
+
} from './repair-manager.mjs';
|
|
4
8
|
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
|
5
9
|
|
|
6
10
|
const ACTIVE_REGISTRATION_STATES = new Set([
|
|
@@ -8,6 +12,8 @@ const ACTIVE_REGISTRATION_STATES = new Set([
|
|
|
8
12
|
]);
|
|
9
13
|
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
|
10
14
|
const ALL_VISIBLE_SENDERS = '*';
|
|
15
|
+
const DEFAULT_CALLBACK_PROBE_TIMEOUT_MS = 120_000;
|
|
16
|
+
const MAX_CALLBACK_PROBE_TIMEOUT_MS = 600_000;
|
|
11
17
|
|
|
12
18
|
function idleConnection() {
|
|
13
19
|
return {
|
|
@@ -60,6 +66,26 @@ function secretRefFor(botId) {
|
|
|
60
66
|
return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
function configuredBotFingerprint(config) {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
id: config.id,
|
|
72
|
+
appId: config.appId,
|
|
73
|
+
secretRef: config.secretRef,
|
|
74
|
+
ownerOpenIds: config.ownerOpenIds,
|
|
75
|
+
domain: config.domain,
|
|
76
|
+
botName: config.botName,
|
|
77
|
+
botOpenId: config.botOpenId,
|
|
78
|
+
activated: config.activated,
|
|
79
|
+
deletionPending: config.deletionPending === true,
|
|
80
|
+
connectedAt: config.connectedAt ?? null,
|
|
81
|
+
createdAt: config.createdAt ?? null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function optionalNonEmptyString(value) {
|
|
86
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
/**
|
|
64
90
|
* Multi-account Feishu orchestration. Each bot owns its credential reference,
|
|
65
91
|
* runtime and session store. Config commits are serialized, while unrelated
|
|
@@ -77,11 +103,13 @@ export class MultiBotDshFeishuController {
|
|
|
77
103
|
#runtimes = new Map();
|
|
78
104
|
#botErrors = new Map();
|
|
79
105
|
#registrations = new Map();
|
|
106
|
+
#activeRepairs = new Map();
|
|
80
107
|
#botOwnership = new Map();
|
|
81
108
|
#latestRegistrationId = null;
|
|
82
109
|
#configTransition = Promise.resolve();
|
|
83
110
|
#botTransitions = new Map();
|
|
84
111
|
#revision = 1;
|
|
112
|
+
#callbackProbeTimeoutMs;
|
|
85
113
|
#closed = false;
|
|
86
114
|
|
|
87
115
|
constructor({
|
|
@@ -93,6 +121,7 @@ export class MultiBotDshFeishuController {
|
|
|
93
121
|
deleteState = async () => {},
|
|
94
122
|
createBotId = makeBotId,
|
|
95
123
|
createRegistrationId = makeRegistrationId,
|
|
124
|
+
callbackProbeTimeoutMs = DEFAULT_CALLBACK_PROBE_TIMEOUT_MS,
|
|
96
125
|
}) {
|
|
97
126
|
if (typeof registerApp !== 'function') throw new Error('registerApp is required');
|
|
98
127
|
if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
|
|
@@ -102,6 +131,11 @@ export class MultiBotDshFeishuController {
|
|
|
102
131
|
}
|
|
103
132
|
if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
|
|
104
133
|
if (typeof deleteState !== 'function') throw new Error('deleteState must be a function');
|
|
134
|
+
if (!Number.isFinite(callbackProbeTimeoutMs)
|
|
135
|
+
|| callbackProbeTimeoutMs <= 0
|
|
136
|
+
|| callbackProbeTimeoutMs > MAX_CALLBACK_PROBE_TIMEOUT_MS) {
|
|
137
|
+
throw new TypeError('callbackProbeTimeoutMs must be between 1 and 600000ms');
|
|
138
|
+
}
|
|
105
139
|
this.#registerApp = registerApp;
|
|
106
140
|
this.#verifyApp = verifyApp;
|
|
107
141
|
this.#credentials = credentials;
|
|
@@ -110,6 +144,7 @@ export class MultiBotDshFeishuController {
|
|
|
110
144
|
this.#deleteState = deleteState;
|
|
111
145
|
this.#createBotId = createBotId;
|
|
112
146
|
this.#createRegistrationId = createRegistrationId;
|
|
147
|
+
this.#callbackProbeTimeoutMs = callbackProbeTimeoutMs;
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
async initialize() {
|
|
@@ -187,12 +222,70 @@ export class MultiBotDshFeishuController {
|
|
|
187
222
|
preset: false,
|
|
188
223
|
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
|
189
224
|
events: { items: { tenant: ['im.message.receive_v1'] } },
|
|
225
|
+
callbacks: { items: ['card.action.trigger'] },
|
|
190
226
|
},
|
|
191
227
|
});
|
|
192
228
|
this.#touch();
|
|
193
229
|
return this.registrationStatus(id);
|
|
194
230
|
}
|
|
195
231
|
|
|
232
|
+
startCallbackRepair(botId, { actorOpenId, chatId } = {}) {
|
|
233
|
+
this.#assertOpen();
|
|
234
|
+
const target = this.#requireBot(botId);
|
|
235
|
+
if (target.deletionPending) throw new Error('Cannot repair a Feishu bot pending deletion');
|
|
236
|
+
|
|
237
|
+
const activeId = this.#activeRepairs.get(botId);
|
|
238
|
+
const active = activeId ? this.#registrations.get(activeId) : null;
|
|
239
|
+
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
|
240
|
+
return this.registrationStatus(active.id);
|
|
241
|
+
}
|
|
242
|
+
this.#activeRepairs.delete(botId);
|
|
243
|
+
|
|
244
|
+
const id = this.#createRegistrationId();
|
|
245
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
|
246
|
+
throw new Error('Registration id generator returned an invalid or duplicate id');
|
|
247
|
+
}
|
|
248
|
+
const record = {
|
|
249
|
+
id,
|
|
250
|
+
operation: CALLBACK_REPAIR_OPERATION,
|
|
251
|
+
manager: null,
|
|
252
|
+
botId,
|
|
253
|
+
createdNew: false,
|
|
254
|
+
cancelled: false,
|
|
255
|
+
remoteCommitted: false,
|
|
256
|
+
processing: null,
|
|
257
|
+
publicError: null,
|
|
258
|
+
stage: 'authorizing',
|
|
259
|
+
target: structuredClone(target),
|
|
260
|
+
targetFingerprint: configuredBotFingerprint(target),
|
|
261
|
+
initiator: {
|
|
262
|
+
actorOpenId: optionalNonEmptyString(actorOpenId),
|
|
263
|
+
chatId: optionalNonEmptyString(chatId),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
record.manager = new CallbackRepairManager({
|
|
267
|
+
registerApp: this.#registerApp,
|
|
268
|
+
appId: target.appId,
|
|
269
|
+
domain: target.domain,
|
|
270
|
+
onCredentials: (result) => {
|
|
271
|
+
record.remoteCommitted = true;
|
|
272
|
+
const processing = this.#acceptCallbackRepair(record, result);
|
|
273
|
+
const tracked = processing.finally(() => {
|
|
274
|
+
if (record.processing === tracked) record.processing = null;
|
|
275
|
+
});
|
|
276
|
+
record.processing = tracked;
|
|
277
|
+
return tracked;
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
this.#registrations.set(id, record);
|
|
281
|
+
this.#activeRepairs.set(botId, id);
|
|
282
|
+
this.#latestRegistrationId = id;
|
|
283
|
+
this.#trimRegistrations();
|
|
284
|
+
record.manager.start();
|
|
285
|
+
this.#touch();
|
|
286
|
+
return this.registrationStatus(id);
|
|
287
|
+
}
|
|
288
|
+
|
|
196
289
|
hasRegistration(attemptId) {
|
|
197
290
|
return this.#registrations.has(attemptId);
|
|
198
291
|
}
|
|
@@ -206,7 +299,14 @@ export class MultiBotDshFeishuController {
|
|
|
206
299
|
async cancelRegistration(attemptId = this.#latestRegistrationId) {
|
|
207
300
|
const record = this.#registrations.get(attemptId);
|
|
208
301
|
if (!record) return this.status();
|
|
209
|
-
|
|
302
|
+
const state = record.manager.status().state;
|
|
303
|
+
// Once registerApp has returned, the platform-side update has committed.
|
|
304
|
+
// A repair must finish converging the returned credential and callback
|
|
305
|
+
// probe; cancelling here cannot roll that remote mutation back.
|
|
306
|
+
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
|
307
|
+
return this.registrationStatus(attemptId);
|
|
308
|
+
}
|
|
309
|
+
if (!MUTABLE_REGISTRATION_STATES.has(state)) {
|
|
210
310
|
return this.registrationStatus(attemptId);
|
|
211
311
|
}
|
|
212
312
|
record.cancelled = true;
|
|
@@ -397,8 +497,15 @@ export class MultiBotDshFeishuController {
|
|
|
397
497
|
async close() {
|
|
398
498
|
if (this.#closed) return;
|
|
399
499
|
this.#closed = true;
|
|
500
|
+
const repairProcessing = [];
|
|
400
501
|
for (const record of this.#registrations.values()) {
|
|
401
|
-
|
|
502
|
+
const state = record.manager.status().state;
|
|
503
|
+
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
|
504
|
+
// Stop projecting an in-flight repair, but do not request its local
|
|
505
|
+
// credential rollback after the remote update has committed.
|
|
506
|
+
record.manager.cancel();
|
|
507
|
+
if (record.processing) repairProcessing.push(record.processing);
|
|
508
|
+
} else if (MUTABLE_REGISTRATION_STATES.has(state)) {
|
|
402
509
|
record.cancelled = true;
|
|
403
510
|
record.manager.cancel();
|
|
404
511
|
}
|
|
@@ -406,6 +513,15 @@ export class MultiBotDshFeishuController {
|
|
|
406
513
|
await this.#configTransition;
|
|
407
514
|
await Promise.allSettled([...this.#botTransitions.values()]);
|
|
408
515
|
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
|
516
|
+
await Promise.allSettled(repairProcessing);
|
|
517
|
+
// A committed repair can be between SDK completion and its serialized
|
|
518
|
+
// credential/runtime transition when close begins. Waiting for the repair
|
|
519
|
+
// can therefore create a replacement runtime after the first drain. Drain
|
|
520
|
+
// both transition queues again, then stop every runtime created by that
|
|
521
|
+
// late forward-convergence work.
|
|
522
|
+
await this.#configTransition;
|
|
523
|
+
await Promise.allSettled([...this.#botTransitions.values()]);
|
|
524
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
|
409
525
|
}
|
|
410
526
|
|
|
411
527
|
#status({ registration, selectedBotId } = {}) {
|
|
@@ -461,9 +577,226 @@ export class MultiBotDshFeishuController {
|
|
|
461
577
|
...snapshot,
|
|
462
578
|
attempt: record.id,
|
|
463
579
|
...(record.botId ? { botId: record.botId } : {}),
|
|
580
|
+
...(record.operation ? { operation: record.operation } : {}),
|
|
581
|
+
...(record.stage ? { stage: record.stage } : {}),
|
|
582
|
+
...(snapshot.state === 'error' && record.publicError
|
|
583
|
+
? { error: { ...record.publicError } }
|
|
584
|
+
: {}),
|
|
464
585
|
};
|
|
465
586
|
}
|
|
466
587
|
|
|
588
|
+
async #acceptCallbackRepair(record, result) {
|
|
589
|
+
const appId = result.client_id;
|
|
590
|
+
const appSecret = result.client_secret;
|
|
591
|
+
const ownerOpenId = optionalNonEmptyString(result.user_info?.open_id);
|
|
592
|
+
const tenantBrand = result.user_info?.tenant_brand;
|
|
593
|
+
const target = record.target;
|
|
594
|
+
|
|
595
|
+
if (record.cancelled) {
|
|
596
|
+
throw this.#callbackRepairError(
|
|
597
|
+
record,
|
|
598
|
+
'abort',
|
|
599
|
+
'Callback repair was cancelled before local activation.',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (appId !== target.appId) {
|
|
603
|
+
throw this.#callbackRepairError(
|
|
604
|
+
record,
|
|
605
|
+
'repair_app_mismatch',
|
|
606
|
+
'Feishu returned credentials for a different app.',
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!ownerOpenId) {
|
|
610
|
+
throw this.#callbackRepairError(
|
|
611
|
+
record,
|
|
612
|
+
'repair_owner_missing',
|
|
613
|
+
'Feishu returned no repair operator identity.',
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (tenantBrand !== undefined && tenantBrand !== target.domain) {
|
|
617
|
+
throw this.#callbackRepairError(
|
|
618
|
+
record,
|
|
619
|
+
'repair_domain_mismatch',
|
|
620
|
+
'Feishu returned credentials for a different account domain.',
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (record.initiator.actorOpenId && record.initiator.actorOpenId !== ownerOpenId) {
|
|
624
|
+
throw this.#callbackRepairError(
|
|
625
|
+
record,
|
|
626
|
+
'repair_owner_mismatch',
|
|
627
|
+
'The Feishu repair was confirmed by a different operator.',
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
|
|
631
|
+
&& !target.ownerOpenIds.includes(ownerOpenId)) {
|
|
632
|
+
throw this.#callbackRepairError(
|
|
633
|
+
record,
|
|
634
|
+
'repair_owner_mismatch',
|
|
635
|
+
'The Feishu repair operator is not an owner of this configured bot.',
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
record.stage = 'verifying_identity';
|
|
640
|
+
let verified;
|
|
641
|
+
try {
|
|
642
|
+
verified = await this.#verifyApp({
|
|
643
|
+
appId,
|
|
644
|
+
appSecret,
|
|
645
|
+
domain: target.domain,
|
|
646
|
+
});
|
|
647
|
+
} catch (error) {
|
|
648
|
+
throw this.#callbackRepairError(
|
|
649
|
+
record,
|
|
650
|
+
'repair_credentials_invalid',
|
|
651
|
+
'Feishu could not verify the repaired app credentials.',
|
|
652
|
+
error,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (target.botOpenId && verified?.openId !== target.botOpenId) {
|
|
656
|
+
throw this.#callbackRepairError(
|
|
657
|
+
record,
|
|
658
|
+
'repair_bot_mismatch',
|
|
659
|
+
'The repaired Feishu app belongs to a different bot identity.',
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const runtime = await this.#serializeConfig(() => this.#withBotTransition(
|
|
664
|
+
record.botId,
|
|
665
|
+
async () => {
|
|
666
|
+
const current = this.#configStore.getBot(record.botId);
|
|
667
|
+
if (!current
|
|
668
|
+
|| current.deletionPending
|
|
669
|
+
|| configuredBotFingerprint(current) !== record.targetFingerprint) {
|
|
670
|
+
throw this.#callbackRepairError(
|
|
671
|
+
record,
|
|
672
|
+
'repair_target_changed',
|
|
673
|
+
'The Feishu bot changed while its callback repair was in progress.',
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let previous;
|
|
678
|
+
try {
|
|
679
|
+
previous = await this.#credentials.resolve(current.secretRef);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
throw this.#callbackRepairError(
|
|
682
|
+
record,
|
|
683
|
+
'credential_update_failed',
|
|
684
|
+
'Unable to read the current Feishu credential.',
|
|
685
|
+
error,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
const credentialChanged = previous?.value !== appSecret;
|
|
689
|
+
if (credentialChanged) {
|
|
690
|
+
record.stage = 'persisting_secret';
|
|
691
|
+
try {
|
|
692
|
+
await this.#credentials.set(current.secretRef, appSecret);
|
|
693
|
+
} catch (writeError) {
|
|
694
|
+
const observed = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
|
695
|
+
if (observed?.value !== appSecret) {
|
|
696
|
+
throw this.#callbackRepairError(
|
|
697
|
+
record,
|
|
698
|
+
'credential_update_failed',
|
|
699
|
+
'Unable to store the repaired Feishu credential.',
|
|
700
|
+
writeError,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const persisted = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
|
705
|
+
if (persisted?.value !== appSecret) {
|
|
706
|
+
throw this.#callbackRepairError(
|
|
707
|
+
record,
|
|
708
|
+
'credential_state_unknown',
|
|
709
|
+
'The repaired Feishu credential could not be confirmed after writing.',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let currentRuntime;
|
|
715
|
+
// Callback subscriptions are delivered over the long connection.
|
|
716
|
+
// Always replace it after the platform-side callback update commits,
|
|
717
|
+
// even when registerApp returns the same secret and the old socket
|
|
718
|
+
// still reports healthy, so the probe never runs on stale metadata.
|
|
719
|
+
record.stage = 'restarting';
|
|
720
|
+
try {
|
|
721
|
+
await this.#startRuntime(current, appSecret);
|
|
722
|
+
currentRuntime = this.#runtimes.get(record.botId);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
// The returned credential was already verified and persisted. Do
|
|
725
|
+
// not restore a potentially revoked old secret; reconnectBot can
|
|
726
|
+
// safely retry this forward state later.
|
|
727
|
+
this.#botErrors.set(record.botId, {
|
|
728
|
+
code: 'connection_failed',
|
|
729
|
+
message: '机器人回调修复已保存,但长连接未就绪,请点击重试。',
|
|
730
|
+
});
|
|
731
|
+
this.#touch();
|
|
732
|
+
throw this.#callbackRepairError(
|
|
733
|
+
record,
|
|
734
|
+
'repair_connection_failed',
|
|
735
|
+
'The repaired Feishu runtime could not be started.',
|
|
736
|
+
error,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (!currentRuntime) {
|
|
740
|
+
throw this.#callbackRepairError(
|
|
741
|
+
record,
|
|
742
|
+
'repair_connection_failed',
|
|
743
|
+
'The repaired Feishu runtime is unavailable.',
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
this.#botErrors.delete(record.botId);
|
|
747
|
+
this.#touch();
|
|
748
|
+
return currentRuntime;
|
|
749
|
+
},
|
|
750
|
+
));
|
|
751
|
+
|
|
752
|
+
if (typeof runtime.beginCardActionProbe !== 'function') {
|
|
753
|
+
throw this.#callbackRepairError(
|
|
754
|
+
record,
|
|
755
|
+
'card_action_probe_unavailable',
|
|
756
|
+
'The Feishu runtime cannot verify card callbacks.',
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
record.stage = 'awaiting_callback';
|
|
760
|
+
try {
|
|
761
|
+
const proof = await runtime.beginCardActionProbe({
|
|
762
|
+
expectedOperatorOpenId: ownerOpenId,
|
|
763
|
+
timeoutMs: this.#callbackProbeTimeoutMs,
|
|
764
|
+
...(record.initiator.chatId ? { chatId: record.initiator.chatId } : {}),
|
|
765
|
+
});
|
|
766
|
+
if (proof?.verified !== true) {
|
|
767
|
+
const error = new Error('Feishu runtime returned no callback proof');
|
|
768
|
+
error.code = 'card_action_probe_failed';
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
const code = error?.code === 'card_action_probe_timeout'
|
|
773
|
+
? 'card_action_probe_timeout'
|
|
774
|
+
: error?.code === 'card_action_probe_unavailable'
|
|
775
|
+
? 'card_action_probe_unavailable'
|
|
776
|
+
: error?.code === 'card_action_probe_send_failed'
|
|
777
|
+
? 'card_action_probe_send_failed'
|
|
778
|
+
: 'card_action_probe_failed';
|
|
779
|
+
throw this.#callbackRepairError(
|
|
780
|
+
record,
|
|
781
|
+
code,
|
|
782
|
+
code === 'card_action_probe_timeout'
|
|
783
|
+
? 'Timed out waiting for the Feishu callback verification button.'
|
|
784
|
+
: 'The Feishu card callback probe failed.',
|
|
785
|
+
error,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
record.stage = 'verified';
|
|
789
|
+
record.publicError = null;
|
|
790
|
+
this.#touch();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#callbackRepairError(record, code, message, cause) {
|
|
794
|
+
record.publicError = { code, message };
|
|
795
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
796
|
+
error.code = code;
|
|
797
|
+
return error;
|
|
798
|
+
}
|
|
799
|
+
|
|
467
800
|
async #acceptCredentials(record, result) {
|
|
468
801
|
if (record.cancelled) throw new Error('Registration was cancelled');
|
|
469
802
|
const appId = result.client_id;
|
|
@@ -610,6 +943,7 @@ export class MultiBotDshFeishuController {
|
|
|
610
943
|
botId: config.id,
|
|
611
944
|
config,
|
|
612
945
|
appSecret,
|
|
946
|
+
repair: this.#runtimeRepairCapability(config.id),
|
|
613
947
|
});
|
|
614
948
|
this.#runtimes.set(config.id, runtime);
|
|
615
949
|
try {
|
|
@@ -621,6 +955,27 @@ export class MultiBotDshFeishuController {
|
|
|
621
955
|
}
|
|
622
956
|
}
|
|
623
957
|
|
|
958
|
+
#runtimeRepairCapability(botId) {
|
|
959
|
+
const ownedAttempt = (attemptId) => {
|
|
960
|
+
const record = this.#registrations.get(attemptId);
|
|
961
|
+
return record?.operation === CALLBACK_REPAIR_OPERATION && record.botId === botId
|
|
962
|
+
? record
|
|
963
|
+
: null;
|
|
964
|
+
};
|
|
965
|
+
return Object.freeze({
|
|
966
|
+
start: ({ actorOpenId, chatId } = {}) => this.startCallbackRepair(botId, {
|
|
967
|
+
actorOpenId,
|
|
968
|
+
chatId,
|
|
969
|
+
}),
|
|
970
|
+
status: ({ attemptId } = {}) => ownedAttempt(attemptId)
|
|
971
|
+
? this.registrationStatus(attemptId)
|
|
972
|
+
: null,
|
|
973
|
+
cancel: async ({ attemptId } = {}) => ownedAttempt(attemptId)
|
|
974
|
+
? this.cancelRegistration(attemptId)
|
|
975
|
+
: this.status(botId),
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
624
979
|
async #stopRuntime(botId) {
|
|
625
980
|
const runtime = this.#runtimes.get(botId);
|
|
626
981
|
this.#runtimes.delete(botId);
|
|
@@ -103,6 +103,7 @@ export class DshFeishuController {
|
|
|
103
103
|
preset: false,
|
|
104
104
|
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
|
105
105
|
events: { items: { tenant: ['im.message.receive_v1'] } },
|
|
106
|
+
callbacks: { items: ['card.action.trigger'] },
|
|
106
107
|
},
|
|
107
108
|
});
|
|
108
109
|
return this.status();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { RegistrationManager } from './registration-manager.mjs';
|
|
2
|
+
|
|
3
|
+
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
|
|
4
|
+
export const CALLBACK_REPAIR_OPERATION = 'callback_repair';
|
|
5
|
+
|
|
6
|
+
function accountsDomain(domain) {
|
|
7
|
+
return domain === 'lark' ? 'accounts.larksuite.com' : 'accounts.feishu.cn';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function launcherDomain(domain) {
|
|
11
|
+
return domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The SDK owns the rest of the verification URL. The repair flow accepts only
|
|
16
|
+
* its target account host and singleton SDK/app/addon parameters, and it can
|
|
17
|
+
* never fall back to the create-only flow. This catches regressions such as a
|
|
18
|
+
* literal `{{client_id}}` before the broken URL reaches the browser.
|
|
19
|
+
*/
|
|
20
|
+
export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error('Feishu callback repair returned an invalid verification URL');
|
|
26
|
+
}
|
|
27
|
+
const supportedDomain = domain === 'feishu' || domain === 'lark';
|
|
28
|
+
const clientIds = url.searchParams.getAll('clientID');
|
|
29
|
+
const transportProviders = url.searchParams.getAll('tp');
|
|
30
|
+
const addons = url.searchParams.getAll('addons');
|
|
31
|
+
const hasPlaceholder = String(expectedAppId).includes('{{')
|
|
32
|
+
|| String(expectedAppId).includes('}}')
|
|
33
|
+
|| [...url.searchParams.values()].some((item) => (
|
|
34
|
+
item.includes('{{') || item.includes('}}')
|
|
35
|
+
));
|
|
36
|
+
if (!supportedDomain
|
|
37
|
+
|| url.protocol !== 'https:'
|
|
38
|
+
// registerApp begins on accounts.* but the SDK deliberately returns the
|
|
39
|
+
// user-facing /page/launcher URL on open.*.
|
|
40
|
+
|| url.hostname !== launcherDomain(domain)
|
|
41
|
+
|| url.port !== ''
|
|
42
|
+
|| url.username !== ''
|
|
43
|
+
|| url.password !== ''
|
|
44
|
+
|| transportProviders.length !== 1
|
|
45
|
+
|| transportProviders[0] !== 'sdk'
|
|
46
|
+
|| clientIds.length !== 1
|
|
47
|
+
|| clientIds[0] !== expectedAppId
|
|
48
|
+
|| url.searchParams.has('createOnly')
|
|
49
|
+
|| addons.length !== 1
|
|
50
|
+
|| !addons[0]?.trim()
|
|
51
|
+
|| hasPlaceholder) {
|
|
52
|
+
throw new Error('Feishu callback repair returned an unsafe verification URL');
|
|
53
|
+
}
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One targeted update attempt for an existing Feishu app. It intentionally
|
|
59
|
+
* shares RegistrationManager's polling/state implementation while fixing the
|
|
60
|
+
* update manifest in one place so callers cannot accidentally add scopes,
|
|
61
|
+
* events, presets, or createOnly.
|
|
62
|
+
*/
|
|
63
|
+
export class CallbackRepairManager {
|
|
64
|
+
#manager;
|
|
65
|
+
#appId;
|
|
66
|
+
#domain;
|
|
67
|
+
|
|
68
|
+
constructor({ registerApp, onCredentials, appId, domain = 'feishu' } = {}) {
|
|
69
|
+
if (typeof registerApp !== 'function') throw new TypeError('registerApp is required');
|
|
70
|
+
if (typeof onCredentials !== 'function') throw new TypeError('onCredentials is required');
|
|
71
|
+
if (typeof appId !== 'string' || !appId.trim()) throw new TypeError('appId is required');
|
|
72
|
+
if (domain !== 'feishu' && domain !== 'lark') throw new TypeError('domain is invalid');
|
|
73
|
+
|
|
74
|
+
this.#appId = appId.trim();
|
|
75
|
+
this.#domain = domain;
|
|
76
|
+
this.#manager = new RegistrationManager({
|
|
77
|
+
registerApp: (options) => registerApp({
|
|
78
|
+
...options,
|
|
79
|
+
onQRCodeReady: (info) => {
|
|
80
|
+
assertCallbackRepairUrl(info?.url, this.#appId, this.#domain);
|
|
81
|
+
options.onQRCodeReady(info);
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
onCredentials,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
start() {
|
|
89
|
+
return this.#manager.start({
|
|
90
|
+
source: 'deepseek-harness-card-action-repair',
|
|
91
|
+
domain: accountsDomain(this.#domain),
|
|
92
|
+
appId: this.#appId,
|
|
93
|
+
addons: {
|
|
94
|
+
preset: false,
|
|
95
|
+
callbacks: { items: [CARD_ACTION_CALLBACK] },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
status() {
|
|
101
|
+
return this.#manager.status();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
cancel() {
|
|
105
|
+
return this.#manager.cancel();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default CallbackRepairManager;
|
|
@@ -85,7 +85,7 @@ async function selectedWorkspacePath(value) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
async function workspacePathSnapshot(harness) {
|
|
88
|
+
export async function workspacePathSnapshot(harness) {
|
|
89
89
|
const listed = await harness.listWorkspaces();
|
|
90
90
|
const currentValue = typeof harness?.currentWorkspace === 'function'
|
|
91
91
|
? harness.currentWorkspace()
|
|
@@ -148,7 +148,7 @@ async function runWorkspaceListCommand(match, harness) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
async function resolveSessionListWorkspace(selector, harness) {
|
|
151
|
+
export async function resolveSessionListWorkspace(selector, harness) {
|
|
152
152
|
if (!selector) {
|
|
153
153
|
if (typeof harness?.currentWorkspace !== 'function') {
|
|
154
154
|
return { error: '当前机器人没有可用的工作区。' };
|