@botcord/daemon 0.2.91 → 0.2.92
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/dist/gateway-control.js +80 -40
- package/package.json +3 -3
- package/src/__tests__/gateway-control.test.ts +372 -0
- package/src/gateway-control.ts +91 -43
package/dist/gateway-control.js
CHANGED
|
@@ -66,6 +66,9 @@ export function createGatewayControl(ctx) {
|
|
|
66
66
|
throw urlErr;
|
|
67
67
|
}
|
|
68
68
|
const cfg = cfgIO.load();
|
|
69
|
+
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
70
|
+
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
71
|
+
const hadExistingProfile = prevProfile !== undefined;
|
|
69
72
|
// accountId must belong to a daemon-bound agent. An empty agent set
|
|
70
73
|
// (no agents provisioned yet) is itself a hard reject — otherwise we
|
|
71
74
|
// would silently accept upserts against a daemon that has nowhere to
|
|
@@ -144,42 +147,62 @@ export function createGatewayControl(ctx) {
|
|
|
144
147
|
else if (params.type === "feishu") {
|
|
145
148
|
const loginId = params.loginId;
|
|
146
149
|
if (!loginId) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
code: "login_account_mismatch",
|
|
167
|
-
message: "feishu login session accountId does not match upsert request",
|
|
168
|
-
},
|
|
169
|
-
};
|
|
150
|
+
if (!prevProfile ||
|
|
151
|
+
prevProfile.type !== "feishu" ||
|
|
152
|
+
prevProfile.accountId !== params.accountId ||
|
|
153
|
+
!prevProfile.appId) {
|
|
154
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
155
|
+
}
|
|
156
|
+
const existing = loadGatewaySecret(params.id);
|
|
157
|
+
if (!existing?.appSecret) {
|
|
158
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
159
|
+
}
|
|
160
|
+
if (params.settings?.domain !== undefined &&
|
|
161
|
+
params.settings.domain !== (prevProfile.domain ?? "feishu")) {
|
|
162
|
+
return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
|
|
163
|
+
}
|
|
164
|
+
secretPayload = { appSecret: existing.appSecret };
|
|
165
|
+
tokenPreviewSource = existing.appSecret;
|
|
166
|
+
feishuAppId = prevProfile.appId;
|
|
167
|
+
feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
|
|
168
|
+
feishuUserOpenId = prevProfile.userOpenId;
|
|
170
169
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
else {
|
|
171
|
+
const resolved = sessions.resolve(loginId);
|
|
172
|
+
if (resolved.state !== "live") {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: resolved.state === "missing"
|
|
176
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
177
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const session = resolved.session;
|
|
181
|
+
if (session.provider !== "feishu") {
|
|
182
|
+
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
183
|
+
}
|
|
184
|
+
if (session.accountId !== params.accountId) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: {
|
|
188
|
+
code: "login_account_mismatch",
|
|
189
|
+
message: "feishu login session accountId does not match upsert request",
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!session.appId || !session.appSecret) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
secretPayload = { appSecret: session.appSecret };
|
|
200
|
+
tokenPreviewSource = session.appSecret;
|
|
201
|
+
feishuAppId = session.appId;
|
|
202
|
+
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
203
|
+
feishuUserOpenId = session.userOpenId;
|
|
204
|
+
sessions.update(loginId, { gatewayId: params.id });
|
|
176
205
|
}
|
|
177
|
-
secretPayload = { appSecret: session.appSecret };
|
|
178
|
-
tokenPreviewSource = session.appSecret;
|
|
179
|
-
feishuAppId = session.appId;
|
|
180
|
-
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
181
|
-
feishuUserOpenId = session.userOpenId;
|
|
182
|
-
sessions.update(loginId, { gatewayId: params.id });
|
|
183
206
|
}
|
|
184
207
|
else {
|
|
185
208
|
return badParams(`upsert_gateway: unknown provider "${params.type}"`);
|
|
@@ -187,9 +210,6 @@ export function createGatewayControl(ctx) {
|
|
|
187
210
|
// W3/W6: remember whether a profile already exists for this id BEFORE we
|
|
188
211
|
// write the secret/config. For UPDATE path, capture previous profile +
|
|
189
212
|
// previous secret so addChannel failure can restore prior state.
|
|
190
|
-
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
191
|
-
const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
|
|
192
|
-
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
193
213
|
// W6: load the previous secret for UPDATE rollback BEFORE overwriting.
|
|
194
214
|
const prevSecret = hadExistingProfile
|
|
195
215
|
? loadGatewaySecret(params.id)
|
|
@@ -253,19 +273,24 @@ export function createGatewayControl(ctx) {
|
|
|
253
273
|
}
|
|
254
274
|
try {
|
|
255
275
|
if (prevProfile) {
|
|
256
|
-
cfgIO.save(
|
|
276
|
+
cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
catch {
|
|
260
280
|
// best-effort
|
|
261
281
|
}
|
|
262
282
|
try {
|
|
263
|
-
if (prevProfile &&
|
|
283
|
+
if (prevProfile &&
|
|
284
|
+
((prevProfile.type === "telegram" && prevSecret?.botToken) ||
|
|
285
|
+
(prevProfile.type === "feishu" && prevSecret?.appSecret))) {
|
|
264
286
|
await ctx.gateway.addChannel(buildChannelConfig({
|
|
265
287
|
...params,
|
|
266
288
|
type: prevProfile.type,
|
|
289
|
+
accountId: prevProfile.accountId,
|
|
267
290
|
enabled: prevProfile.enabled !== false,
|
|
268
|
-
|
|
291
|
+
...(prevProfile.type === "telegram"
|
|
292
|
+
? { secret: { botToken: prevSecret.botToken } }
|
|
293
|
+
: {}),
|
|
269
294
|
settings: {
|
|
270
295
|
baseUrl: prevProfile.baseUrl,
|
|
271
296
|
allowedSenderIds: prevProfile.allowedSenderIds,
|
|
@@ -854,6 +879,9 @@ function validateOutboundConversation(profile, conversationId) {
|
|
|
854
879
|
},
|
|
855
880
|
};
|
|
856
881
|
}
|
|
882
|
+
if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
857
885
|
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
858
886
|
if (!allowed.has(chatId)) {
|
|
859
887
|
return {
|
|
@@ -929,6 +957,18 @@ function upsertProfileInConfig(cfg, patch) {
|
|
|
929
957
|
}
|
|
930
958
|
return { ...cfg, thirdPartyGateways: list };
|
|
931
959
|
}
|
|
960
|
+
function replaceProfileInConfig(cfg, profile) {
|
|
961
|
+
const list = (cfg.thirdPartyGateways ?? []).slice();
|
|
962
|
+
const idx = list.findIndex((g) => g.id === profile.id);
|
|
963
|
+
const compact = compactProfile(profile);
|
|
964
|
+
if (idx >= 0) {
|
|
965
|
+
list[idx] = compact;
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
list.push(compact);
|
|
969
|
+
}
|
|
970
|
+
return { ...cfg, thirdPartyGateways: list };
|
|
971
|
+
}
|
|
932
972
|
function compactProfile(p) {
|
|
933
973
|
const out = {
|
|
934
974
|
id: p.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.92",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
25
25
|
"ws": "^8.20.1",
|
|
26
|
-
"@botcord/
|
|
27
|
-
"@botcord/
|
|
26
|
+
"@botcord/cli": "^0.1.19",
|
|
27
|
+
"@botcord/protocol-core": "^0.2.13"
|
|
28
28
|
},
|
|
29
29
|
"overrides": {
|
|
30
30
|
"axios": "^1.15.2"
|
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import type { DaemonConfig } from "../config.js";
|
|
7
|
+
import { saveGatewaySecret } from "../gateway/channels/secret-store.js";
|
|
7
8
|
import { LoginSessionStore } from "../gateway/channels/login-session.js";
|
|
8
9
|
import { createGatewayControl } from "../gateway-control.js";
|
|
9
10
|
|
|
@@ -324,6 +325,129 @@ describe("gateway_login_start / status", () => {
|
|
|
324
325
|
const secretPath = trackSecret(gwId);
|
|
325
326
|
const secret = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
|
|
326
327
|
expect(secret.appSecret).toBe("feishu-secret-1234567890");
|
|
328
|
+
|
|
329
|
+
const updateAck = await ctrl.handleUpsert({
|
|
330
|
+
id: gwId,
|
|
331
|
+
type: "feishu",
|
|
332
|
+
accountId: "ag_alice",
|
|
333
|
+
enabled: true,
|
|
334
|
+
settings: {
|
|
335
|
+
allowedSenderIds: ["ou_bob"],
|
|
336
|
+
allowedChatIds: ["oc_team"],
|
|
337
|
+
domain: "feishu",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
expect(updateAck.ok).toBe(true);
|
|
341
|
+
expect(state.cfg.thirdPartyGateways?.[0]).toMatchObject({
|
|
342
|
+
id: gwId,
|
|
343
|
+
type: "feishu",
|
|
344
|
+
appId: "cli_feishu_123",
|
|
345
|
+
allowedSenderIds: ["ou_bob"],
|
|
346
|
+
allowedChatIds: ["oc_team"],
|
|
347
|
+
});
|
|
348
|
+
expect(gw.addChannel).toHaveBeenLastCalledWith(
|
|
349
|
+
expect.objectContaining({
|
|
350
|
+
id: gwId,
|
|
351
|
+
type: "feishu",
|
|
352
|
+
appId: "cli_feishu_123",
|
|
353
|
+
allowedChatIds: ["oc_team"],
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects changing Feishu domain without a fresh loginId", async () => {
|
|
359
|
+
const gw = makeFakeGateway();
|
|
360
|
+
const { io } = makeConfigIO(baseCfg());
|
|
361
|
+
const sessions = new LoginSessionStore();
|
|
362
|
+
const ctrl = createGatewayControl({
|
|
363
|
+
gateway: gw as any,
|
|
364
|
+
configIO: io,
|
|
365
|
+
loginSessions: sessions,
|
|
366
|
+
});
|
|
367
|
+
const gwId = uniqId("fs-domain");
|
|
368
|
+
trackSecret(gwId);
|
|
369
|
+
|
|
370
|
+
sessions.create({
|
|
371
|
+
loginId: "fsl_domain_seed",
|
|
372
|
+
accountId: "ag_alice",
|
|
373
|
+
provider: "feishu",
|
|
374
|
+
appId: "cli_domain_seed",
|
|
375
|
+
appSecret: "feishu-domain-secret-seed",
|
|
376
|
+
domain: "feishu",
|
|
377
|
+
userOpenId: "ou_domain_seed",
|
|
378
|
+
});
|
|
379
|
+
const installAck = await ctrl.handleUpsert({
|
|
380
|
+
id: gwId,
|
|
381
|
+
type: "feishu",
|
|
382
|
+
accountId: "ag_alice",
|
|
383
|
+
enabled: true,
|
|
384
|
+
loginId: "fsl_domain_seed",
|
|
385
|
+
settings: { domain: "feishu" },
|
|
386
|
+
});
|
|
387
|
+
expect(installAck.ok).toBe(true);
|
|
388
|
+
|
|
389
|
+
const updateAck = await ctrl.handleUpsert({
|
|
390
|
+
id: gwId,
|
|
391
|
+
type: "feishu",
|
|
392
|
+
accountId: "ag_alice",
|
|
393
|
+
enabled: true,
|
|
394
|
+
settings: { domain: "lark" },
|
|
395
|
+
});
|
|
396
|
+
expect(updateAck.ok).toBe(false);
|
|
397
|
+
expect(updateAck.error?.code).toBe("bad_params");
|
|
398
|
+
expect(updateAck.error?.message).toContain("domain change requires a fresh loginId");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("rejects changing Feishu domain without loginId when saved profile omits domain (implicit feishu)", async () => {
|
|
402
|
+
const gw = makeFakeGateway();
|
|
403
|
+
const { state, io } = makeConfigIO(baseCfg());
|
|
404
|
+
const sessions = new LoginSessionStore();
|
|
405
|
+
const ctrl = createGatewayControl({
|
|
406
|
+
gateway: gw as any,
|
|
407
|
+
configIO: io,
|
|
408
|
+
loginSessions: sessions,
|
|
409
|
+
});
|
|
410
|
+
const gwId = uniqId("fs-domain-implicit");
|
|
411
|
+
trackSecret(gwId);
|
|
412
|
+
|
|
413
|
+
sessions.create({
|
|
414
|
+
loginId: "fsl_domain_implicit_seed",
|
|
415
|
+
accountId: "ag_alice",
|
|
416
|
+
provider: "feishu",
|
|
417
|
+
appId: "cli_domain_implicit_seed",
|
|
418
|
+
appSecret: "feishu-domain-implicit-secret-seed",
|
|
419
|
+
domain: "feishu",
|
|
420
|
+
userOpenId: "ou_domain_implicit_seed",
|
|
421
|
+
});
|
|
422
|
+
const installAck = await ctrl.handleUpsert({
|
|
423
|
+
id: gwId,
|
|
424
|
+
type: "feishu",
|
|
425
|
+
accountId: "ag_alice",
|
|
426
|
+
enabled: true,
|
|
427
|
+
loginId: "fsl_domain_implicit_seed",
|
|
428
|
+
settings: { domain: "feishu" },
|
|
429
|
+
});
|
|
430
|
+
expect(installAck.ok).toBe(true);
|
|
431
|
+
|
|
432
|
+
// Simulate legacy/missing persisted domain. The daemon should treat this
|
|
433
|
+
// as implicit "feishu" when validating no-login domain changes.
|
|
434
|
+
const saved = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
|
|
435
|
+
expect(saved).toBeDefined();
|
|
436
|
+
if (saved) {
|
|
437
|
+
delete saved.domain;
|
|
438
|
+
io.save(state.cfg);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const updateAck = await ctrl.handleUpsert({
|
|
442
|
+
id: gwId,
|
|
443
|
+
type: "feishu",
|
|
444
|
+
accountId: "ag_alice",
|
|
445
|
+
enabled: true,
|
|
446
|
+
settings: { domain: "lark" },
|
|
447
|
+
});
|
|
448
|
+
expect(updateAck.ok).toBe(false);
|
|
449
|
+
expect(updateAck.error?.code).toBe("bad_params");
|
|
450
|
+
expect(updateAck.error?.message).toContain("domain change requires a fresh loginId");
|
|
327
451
|
});
|
|
328
452
|
|
|
329
453
|
it("discovers recent WeChat senders from a confirmed login session", async () => {
|
|
@@ -584,6 +708,170 @@ describe("W6: UPDATE rollback on addChannel failure", () => {
|
|
|
584
708
|
expect(onDisk.botToken).toBe("old-token:123456789012345");
|
|
585
709
|
}
|
|
586
710
|
});
|
|
711
|
+
|
|
712
|
+
it("restores previous Feishu secret/config and re-adds old channel when update addChannel fails", async () => {
|
|
713
|
+
const gw = makeFakeGateway();
|
|
714
|
+
const { state, io } = makeConfigIO({ ...baseCfg(), agents: ["ag_alice", "ag_bob"] });
|
|
715
|
+
const sessions = new LoginSessionStore();
|
|
716
|
+
const ctrl = createGatewayControl({
|
|
717
|
+
gateway: gw as any,
|
|
718
|
+
configIO: io,
|
|
719
|
+
loginSessions: sessions,
|
|
720
|
+
});
|
|
721
|
+
const gwId = uniqId("w6fs");
|
|
722
|
+
trackSecret(gwId);
|
|
723
|
+
|
|
724
|
+
sessions.create({
|
|
725
|
+
loginId: "fsl_w6_old",
|
|
726
|
+
accountId: "ag_alice",
|
|
727
|
+
provider: "feishu",
|
|
728
|
+
appId: "cli_old",
|
|
729
|
+
appSecret: "old-feishu-secret-12345",
|
|
730
|
+
userOpenId: "ou_old",
|
|
731
|
+
domain: "feishu",
|
|
732
|
+
});
|
|
733
|
+
const firstAck = await ctrl.handleUpsert({
|
|
734
|
+
id: gwId,
|
|
735
|
+
type: "feishu",
|
|
736
|
+
accountId: "ag_alice",
|
|
737
|
+
enabled: true,
|
|
738
|
+
loginId: "fsl_w6_old",
|
|
739
|
+
settings: { allowedChatIds: ["oc_old"], domain: "feishu" },
|
|
740
|
+
});
|
|
741
|
+
expect(firstAck.ok).toBe(true);
|
|
742
|
+
|
|
743
|
+
sessions.create({
|
|
744
|
+
loginId: "fsl_w6_new",
|
|
745
|
+
accountId: "ag_bob",
|
|
746
|
+
provider: "feishu",
|
|
747
|
+
appId: "cli_new",
|
|
748
|
+
appSecret: "new-feishu-secret-ABCDE",
|
|
749
|
+
userOpenId: "ou_new",
|
|
750
|
+
domain: "lark",
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
let addCallCount = 0;
|
|
754
|
+
gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
|
|
755
|
+
addCallCount += 1;
|
|
756
|
+
if (addCallCount === 1) throw new Error("simulated feishu update failure");
|
|
757
|
+
gw.channels.set(cfg.id, {
|
|
758
|
+
id: cfg.id,
|
|
759
|
+
status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const updateAck = await ctrl.handleUpsert({
|
|
764
|
+
id: gwId,
|
|
765
|
+
type: "feishu",
|
|
766
|
+
accountId: "ag_bob",
|
|
767
|
+
enabled: true,
|
|
768
|
+
loginId: "fsl_w6_new",
|
|
769
|
+
settings: { allowedChatIds: ["oc_new"], domain: "lark" },
|
|
770
|
+
});
|
|
771
|
+
expect(updateAck.ok).toBe(false);
|
|
772
|
+
expect(updateAck.error?.code).toBe("addChannel_failed");
|
|
773
|
+
expect(addCallCount).toBe(2);
|
|
774
|
+
|
|
775
|
+
const profile = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
|
|
776
|
+
expect(profile).toMatchObject({
|
|
777
|
+
id: gwId,
|
|
778
|
+
type: "feishu",
|
|
779
|
+
appId: "cli_old",
|
|
780
|
+
domain: "feishu",
|
|
781
|
+
userOpenId: "ou_old",
|
|
782
|
+
allowedChatIds: ["oc_old"],
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const secretPath = trackSecret(gwId);
|
|
786
|
+
const onDisk = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
|
|
787
|
+
expect(onDisk.appSecret).toBe("old-feishu-secret-12345");
|
|
788
|
+
expect(gw.addChannel).toHaveBeenLastCalledWith(
|
|
789
|
+
expect.objectContaining({
|
|
790
|
+
id: gwId,
|
|
791
|
+
type: "feishu",
|
|
792
|
+
accountId: "ag_alice",
|
|
793
|
+
appId: "cli_old",
|
|
794
|
+
domain: "feishu",
|
|
795
|
+
allowedChatIds: ["oc_old"],
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("replaces previous Feishu profile on rollback so failed update fields are removed", async () => {
|
|
801
|
+
const gw = makeFakeGateway();
|
|
802
|
+
const gwId = uniqId("w6fs-replace");
|
|
803
|
+
trackSecret(gwId);
|
|
804
|
+
saveGatewaySecret(gwId, { appSecret: "old-feishu-secret-replace" });
|
|
805
|
+
const { state, io } = makeConfigIO({
|
|
806
|
+
...baseCfg(),
|
|
807
|
+
agents: ["ag_alice", "ag_bob"],
|
|
808
|
+
thirdPartyGateways: [
|
|
809
|
+
{
|
|
810
|
+
id: gwId,
|
|
811
|
+
type: "feishu",
|
|
812
|
+
accountId: "ag_alice",
|
|
813
|
+
enabled: true,
|
|
814
|
+
appId: "cli_old_replace",
|
|
815
|
+
},
|
|
816
|
+
],
|
|
817
|
+
});
|
|
818
|
+
const sessions = new LoginSessionStore();
|
|
819
|
+
const ctrl = createGatewayControl({
|
|
820
|
+
gateway: gw as any,
|
|
821
|
+
configIO: io,
|
|
822
|
+
loginSessions: sessions,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
sessions.create({
|
|
826
|
+
loginId: "fsl_w6_replace_new",
|
|
827
|
+
accountId: "ag_bob",
|
|
828
|
+
provider: "feishu",
|
|
829
|
+
appId: "cli_new_replace",
|
|
830
|
+
appSecret: "new-feishu-secret-replace",
|
|
831
|
+
userOpenId: "ou_new_replace",
|
|
832
|
+
domain: "lark",
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
let addCallCount = 0;
|
|
836
|
+
gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
|
|
837
|
+
addCallCount += 1;
|
|
838
|
+
if (addCallCount === 1) throw new Error("simulated feishu update failure");
|
|
839
|
+
gw.channels.set(cfg.id, {
|
|
840
|
+
id: cfg.id,
|
|
841
|
+
status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const updateAck = await ctrl.handleUpsert({
|
|
846
|
+
id: gwId,
|
|
847
|
+
type: "feishu",
|
|
848
|
+
accountId: "ag_bob",
|
|
849
|
+
enabled: true,
|
|
850
|
+
loginId: "fsl_w6_replace_new",
|
|
851
|
+
settings: {
|
|
852
|
+
allowedSenderIds: ["ou_new_replace"],
|
|
853
|
+
allowedChatIds: ["oc_new_replace"],
|
|
854
|
+
splitAt: 2000,
|
|
855
|
+
domain: "lark",
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(updateAck.ok).toBe(false);
|
|
860
|
+
expect(updateAck.error?.code).toBe("addChannel_failed");
|
|
861
|
+
expect(addCallCount).toBe(2);
|
|
862
|
+
expect(state.cfg.thirdPartyGateways).toEqual([
|
|
863
|
+
{
|
|
864
|
+
id: gwId,
|
|
865
|
+
type: "feishu",
|
|
866
|
+
accountId: "ag_alice",
|
|
867
|
+
enabled: true,
|
|
868
|
+
appId: "cli_old_replace",
|
|
869
|
+
},
|
|
870
|
+
]);
|
|
871
|
+
|
|
872
|
+
const onDisk = JSON.parse(readFileSync(trackSecret(gwId), "utf8")) as { appSecret?: string };
|
|
873
|
+
expect(onDisk.appSecret).toBe("old-feishu-secret-replace");
|
|
874
|
+
});
|
|
587
875
|
});
|
|
588
876
|
|
|
589
877
|
describe("list_gateways", () => {
|
|
@@ -686,6 +974,90 @@ describe("gateway_send", () => {
|
|
|
686
974
|
expect(ack.error?.code).toBe("conversation_not_allowed");
|
|
687
975
|
expect(gw.sendOutbound).not.toHaveBeenCalled();
|
|
688
976
|
});
|
|
977
|
+
|
|
978
|
+
it("allows Feishu outbound when allowedChatIds is empty", async () => {
|
|
979
|
+
const gw = makeFakeGateway();
|
|
980
|
+
const gwId = uniqId("send-fs-allow-all");
|
|
981
|
+
const { io } = makeConfigIO({
|
|
982
|
+
...baseCfg(),
|
|
983
|
+
thirdPartyGateways: [
|
|
984
|
+
{
|
|
985
|
+
id: gwId,
|
|
986
|
+
type: "feishu",
|
|
987
|
+
accountId: "ag_alice",
|
|
988
|
+
enabled: true,
|
|
989
|
+
allowedChatIds: [],
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
});
|
|
993
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
994
|
+
|
|
995
|
+
const ack = await ctrl.handleSend({
|
|
996
|
+
agentId: "ag_alice",
|
|
997
|
+
gatewayId: gwId,
|
|
998
|
+
conversationId: "feishu:chat:oc_any",
|
|
999
|
+
text: "hello",
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
expect(ack.ok).toBe(true);
|
|
1003
|
+
expect(gw.sendOutbound).toHaveBeenCalledOnce();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("allows Feishu outbound when allowedChatIds is omitted", async () => {
|
|
1007
|
+
const gw = makeFakeGateway();
|
|
1008
|
+
const gwId = uniqId("send-fs-allow-omitted");
|
|
1009
|
+
const { io } = makeConfigIO({
|
|
1010
|
+
...baseCfg(),
|
|
1011
|
+
thirdPartyGateways: [
|
|
1012
|
+
{
|
|
1013
|
+
id: gwId,
|
|
1014
|
+
type: "feishu",
|
|
1015
|
+
accountId: "ag_alice",
|
|
1016
|
+
enabled: true,
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
});
|
|
1020
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
1021
|
+
|
|
1022
|
+
const ack = await ctrl.handleSend({
|
|
1023
|
+
agentId: "ag_alice",
|
|
1024
|
+
gatewayId: gwId,
|
|
1025
|
+
conversationId: "feishu:chat:oc_any",
|
|
1026
|
+
text: "hello",
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
expect(ack.ok).toBe(true);
|
|
1030
|
+
expect(gw.sendOutbound).toHaveBeenCalledOnce();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("denies Telegram outbound when allowedChatIds is empty", async () => {
|
|
1034
|
+
const gw = makeFakeGateway();
|
|
1035
|
+
const gwId = uniqId("send-tg-deny-empty");
|
|
1036
|
+
const { io } = makeConfigIO({
|
|
1037
|
+
...baseCfg(),
|
|
1038
|
+
thirdPartyGateways: [
|
|
1039
|
+
{
|
|
1040
|
+
id: gwId,
|
|
1041
|
+
type: "telegram",
|
|
1042
|
+
accountId: "ag_alice",
|
|
1043
|
+
enabled: true,
|
|
1044
|
+
allowedChatIds: [],
|
|
1045
|
+
},
|
|
1046
|
+
],
|
|
1047
|
+
});
|
|
1048
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
1049
|
+
|
|
1050
|
+
const ack = await ctrl.handleSend({
|
|
1051
|
+
agentId: "ag_alice",
|
|
1052
|
+
gatewayId: gwId,
|
|
1053
|
+
conversationId: "telegram:group:-100123",
|
|
1054
|
+
text: "hello",
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
expect(ack.ok).toBe(false);
|
|
1058
|
+
expect(ack.error?.code).toBe("conversation_not_allowed");
|
|
1059
|
+
expect(gw.sendOutbound).not.toHaveBeenCalled();
|
|
1060
|
+
});
|
|
689
1061
|
});
|
|
690
1062
|
|
|
691
1063
|
describe("W4: handleLoginStatus accountId ownership check", () => {
|
package/src/gateway-control.ts
CHANGED
|
@@ -271,6 +271,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
const cfg = cfgIO.load();
|
|
274
|
+
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
275
|
+
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
276
|
+
const hadExistingProfile = prevProfile !== undefined;
|
|
274
277
|
|
|
275
278
|
// accountId must belong to a daemon-bound agent. An empty agent set
|
|
276
279
|
// (no agents provisioned yet) is itself a hard reject — otherwise we
|
|
@@ -349,43 +352,66 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
349
352
|
} else if (params.type === "feishu") {
|
|
350
353
|
const loginId = params.loginId;
|
|
351
354
|
if (!loginId) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
355
|
+
if (
|
|
356
|
+
!prevProfile ||
|
|
357
|
+
prevProfile.type !== "feishu" ||
|
|
358
|
+
prevProfile.accountId !== params.accountId ||
|
|
359
|
+
!prevProfile.appId
|
|
360
|
+
) {
|
|
361
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
362
|
+
}
|
|
363
|
+
const existing = loadGatewaySecret<{ appSecret?: string }>(params.id);
|
|
364
|
+
if (!existing?.appSecret) {
|
|
365
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
params.settings?.domain !== undefined &&
|
|
369
|
+
params.settings.domain !== (prevProfile.domain ?? "feishu")
|
|
370
|
+
) {
|
|
371
|
+
return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
|
|
372
|
+
}
|
|
373
|
+
secretPayload = { appSecret: existing.appSecret };
|
|
374
|
+
tokenPreviewSource = existing.appSecret;
|
|
375
|
+
feishuAppId = prevProfile.appId;
|
|
376
|
+
feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
|
|
377
|
+
feishuUserOpenId = prevProfile.userOpenId;
|
|
378
|
+
} else {
|
|
379
|
+
const resolved = sessions.resolve(loginId);
|
|
380
|
+
if (resolved.state !== "live") {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
error:
|
|
384
|
+
resolved.state === "missing"
|
|
385
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
386
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const session = resolved.session!;
|
|
390
|
+
if (session.provider !== "feishu") {
|
|
391
|
+
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
392
|
+
}
|
|
393
|
+
if (session.accountId !== params.accountId) {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: {
|
|
397
|
+
code: "login_account_mismatch",
|
|
398
|
+
message: "feishu login session accountId does not match upsert request",
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (!session.appId || !session.appSecret) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
secretPayload = { appSecret: session.appSecret };
|
|
409
|
+
tokenPreviewSource = session.appSecret;
|
|
410
|
+
feishuAppId = session.appId;
|
|
411
|
+
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
412
|
+
feishuUserOpenId = session.userOpenId;
|
|
413
|
+
sessions.update(loginId, { gatewayId: params.id });
|
|
382
414
|
}
|
|
383
|
-
secretPayload = { appSecret: session.appSecret };
|
|
384
|
-
tokenPreviewSource = session.appSecret;
|
|
385
|
-
feishuAppId = session.appId;
|
|
386
|
-
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
387
|
-
feishuUserOpenId = session.userOpenId;
|
|
388
|
-
sessions.update(loginId, { gatewayId: params.id });
|
|
389
415
|
} else {
|
|
390
416
|
return badParams(`upsert_gateway: unknown provider "${(params as { type: string }).type}"`);
|
|
391
417
|
}
|
|
@@ -393,12 +419,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
393
419
|
// W3/W6: remember whether a profile already exists for this id BEFORE we
|
|
394
420
|
// write the secret/config. For UPDATE path, capture previous profile +
|
|
395
421
|
// previous secret so addChannel failure can restore prior state.
|
|
396
|
-
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
397
|
-
const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
|
|
398
|
-
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
399
422
|
// W6: load the previous secret for UPDATE rollback BEFORE overwriting.
|
|
400
423
|
const prevSecret = hadExistingProfile
|
|
401
|
-
? loadGatewaySecret<{ botToken?: string }>(params.id)
|
|
424
|
+
? loadGatewaySecret<{ botToken?: string; appSecret?: string }>(params.id)
|
|
402
425
|
: null;
|
|
403
426
|
|
|
404
427
|
// Persist secret first (so a config write that succeeds is never
|
|
@@ -458,20 +481,27 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
458
481
|
}
|
|
459
482
|
try {
|
|
460
483
|
if (prevProfile) {
|
|
461
|
-
cfgIO.save(
|
|
484
|
+
cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
|
|
462
485
|
}
|
|
463
486
|
} catch {
|
|
464
487
|
// best-effort
|
|
465
488
|
}
|
|
466
489
|
try {
|
|
467
|
-
if (
|
|
490
|
+
if (
|
|
491
|
+
prevProfile &&
|
|
492
|
+
((prevProfile.type === "telegram" && prevSecret?.botToken) ||
|
|
493
|
+
(prevProfile.type === "feishu" && prevSecret?.appSecret))
|
|
494
|
+
) {
|
|
468
495
|
await ctx.gateway.addChannel(
|
|
469
496
|
buildChannelConfig(
|
|
470
497
|
{
|
|
471
498
|
...params,
|
|
472
499
|
type: prevProfile.type as typeof params.type,
|
|
500
|
+
accountId: prevProfile.accountId,
|
|
473
501
|
enabled: prevProfile.enabled !== false,
|
|
474
|
-
|
|
502
|
+
...(prevProfile.type === "telegram"
|
|
503
|
+
? { secret: { botToken: prevSecret.botToken } }
|
|
504
|
+
: {}),
|
|
475
505
|
settings: {
|
|
476
506
|
baseUrl: prevProfile.baseUrl,
|
|
477
507
|
allowedSenderIds: prevProfile.allowedSenderIds,
|
|
@@ -1074,6 +1104,9 @@ function validateOutboundConversation(
|
|
|
1074
1104
|
},
|
|
1075
1105
|
};
|
|
1076
1106
|
}
|
|
1107
|
+
if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1077
1110
|
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
1078
1111
|
if (!allowed.has(chatId)) {
|
|
1079
1112
|
return {
|
|
@@ -1161,6 +1194,21 @@ function upsertProfileInConfig(
|
|
|
1161
1194
|
return { ...cfg, thirdPartyGateways: list };
|
|
1162
1195
|
}
|
|
1163
1196
|
|
|
1197
|
+
function replaceProfileInConfig(
|
|
1198
|
+
cfg: DaemonConfig,
|
|
1199
|
+
profile: ThirdPartyGatewayProfile,
|
|
1200
|
+
): DaemonConfig {
|
|
1201
|
+
const list = (cfg.thirdPartyGateways ?? []).slice();
|
|
1202
|
+
const idx = list.findIndex((g) => g.id === profile.id);
|
|
1203
|
+
const compact = compactProfile(profile);
|
|
1204
|
+
if (idx >= 0) {
|
|
1205
|
+
list[idx] = compact;
|
|
1206
|
+
} else {
|
|
1207
|
+
list.push(compact);
|
|
1208
|
+
}
|
|
1209
|
+
return { ...cfg, thirdPartyGateways: list };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1164
1212
|
function compactProfile(p: ThirdPartyGatewayProfile): ThirdPartyGatewayProfile {
|
|
1165
1213
|
const out: ThirdPartyGatewayProfile = {
|
|
1166
1214
|
id: p.id,
|