@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.
@@ -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
- return badParams("upsert_gateway: feishu requires loginId");
148
- }
149
- const resolved = sessions.resolve(loginId);
150
- if (resolved.state !== "live") {
151
- return {
152
- ok: false,
153
- error: resolved.state === "missing"
154
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
155
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
156
- };
157
- }
158
- const session = resolved.session;
159
- if (session.provider !== "feishu") {
160
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
161
- }
162
- if (session.accountId !== params.accountId) {
163
- return {
164
- ok: false,
165
- error: {
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
- if (!session.appId || !session.appSecret) {
172
- return {
173
- ok: false,
174
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
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(upsertProfileInConfig(cfgIO.load(), prevProfile));
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 && prevSecret?.botToken) {
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
- secret: { botToken: prevSecret.botToken },
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.91",
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/protocol-core": "^0.2.13",
27
- "@botcord/cli": "^0.1.19"
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", () => {
@@ -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
- return badParams("upsert_gateway: feishu requires loginId");
353
- }
354
- const resolved = sessions.resolve(loginId);
355
- if (resolved.state !== "live") {
356
- return {
357
- ok: false,
358
- error:
359
- resolved.state === "missing"
360
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
361
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
362
- };
363
- }
364
- const session = resolved.session!;
365
- if (session.provider !== "feishu") {
366
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
367
- }
368
- if (session.accountId !== params.accountId) {
369
- return {
370
- ok: false,
371
- error: {
372
- code: "login_account_mismatch",
373
- message: "feishu login session accountId does not match upsert request",
374
- },
375
- };
376
- }
377
- if (!session.appId || !session.appSecret) {
378
- return {
379
- ok: false,
380
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
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(upsertProfileInConfig(cfgIO.load(), prevProfile));
484
+ cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
462
485
  }
463
486
  } catch {
464
487
  // best-effort
465
488
  }
466
489
  try {
467
- if (prevProfile && prevSecret?.botToken) {
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
- secret: { botToken: prevSecret.botToken },
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,