@botcord/daemon 0.2.91 → 0.2.93

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 (44) hide show
  1. package/dist/gateway/channels/botcord.d.ts +9 -1
  2. package/dist/gateway/channels/botcord.js +55 -2
  3. package/dist/gateway/channels/feishu.d.ts +56 -0
  4. package/dist/gateway/channels/feishu.js +76 -0
  5. package/dist/gateway/cli-resolver.d.ts +1 -0
  6. package/dist/gateway/cli-resolver.js +2 -0
  7. package/dist/gateway/dispatcher.d.ts +20 -0
  8. package/dist/gateway/dispatcher.js +252 -0
  9. package/dist/gateway/runtimes/codex.js +1 -0
  10. package/dist/gateway/runtimes/deepseek-tui.js +1 -0
  11. package/dist/gateway/runtimes/hermes-agent.js +1 -0
  12. package/dist/gateway/runtimes/kimi.js +1 -0
  13. package/dist/gateway/runtimes/ndjson-stream.js +1 -0
  14. package/dist/gateway/types.d.ts +8 -0
  15. package/dist/gateway/wait-marker.d.ts +32 -0
  16. package/dist/gateway/wait-marker.js +96 -0
  17. package/dist/gateway-control.d.ts +4 -0
  18. package/dist/gateway-control.js +124 -44
  19. package/dist/loop-risk.js +2 -0
  20. package/dist/system-context.js +3 -0
  21. package/dist/turn-text.js +5 -0
  22. package/package.json +3 -3
  23. package/src/__tests__/feishu-channel.test.ts +180 -0
  24. package/src/__tests__/gateway-control.test.ts +493 -0
  25. package/src/__tests__/system-context.test.ts +4 -0
  26. package/src/gateway/__tests__/botcord-channel.test.ts +50 -0
  27. package/src/gateway/__tests__/dispatcher-park.test.ts +207 -0
  28. package/src/gateway/__tests__/dispatcher.test.ts +48 -1
  29. package/src/gateway/__tests__/wait-marker.test.ts +90 -0
  30. package/src/gateway/channels/botcord.ts +79 -5
  31. package/src/gateway/channels/feishu.ts +122 -0
  32. package/src/gateway/cli-resolver.ts +2 -0
  33. package/src/gateway/dispatcher.ts +292 -0
  34. package/src/gateway/runtimes/codex.ts +1 -0
  35. package/src/gateway/runtimes/deepseek-tui.ts +1 -0
  36. package/src/gateway/runtimes/hermes-agent.ts +1 -0
  37. package/src/gateway/runtimes/kimi.ts +1 -0
  38. package/src/gateway/runtimes/ndjson-stream.ts +1 -0
  39. package/src/gateway/types.ts +8 -0
  40. package/src/gateway/wait-marker.ts +101 -0
  41. package/src/gateway-control.ts +150 -48
  42. package/src/loop-risk.ts +1 -0
  43. package/src/system-context.ts +3 -0
  44. package/src/turn-text.ts +5 -0
@@ -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 () => {
@@ -406,6 +530,127 @@ describe("gateway_login_start / status", () => {
406
530
  expect(ack.ok).toBe(false);
407
531
  expect(ack.error?.code).toBe("login_unconfirmed");
408
532
  });
533
+
534
+ it("discovers Feishu chats from a confirmed login session owned by the account", async () => {
535
+ const gw = makeFakeGateway();
536
+ const { io } = makeConfigIO(baseCfg());
537
+ const sessions = new LoginSessionStore();
538
+ sessions.create({
539
+ loginId: "fsl_discover",
540
+ accountId: "ag_alice",
541
+ provider: "feishu",
542
+ qrcode: "DEVICE",
543
+ appId: "cli_feishu_123",
544
+ appSecret: "feishu-secret-1234567890",
545
+ domain: "feishu",
546
+ userOpenId: "ou_alice",
547
+ });
548
+ const discoverChats = vi.fn(async (opts) => {
549
+ expect(opts).toEqual({
550
+ appId: "cli_feishu_123",
551
+ appSecret: "feishu-secret-1234567890",
552
+ domain: "feishu",
553
+ userOpenId: "ou_alice",
554
+ timeoutSeconds: 6,
555
+ });
556
+ return [
557
+ {
558
+ chatId: "oc_team",
559
+ senderOpenId: "ou_alice",
560
+ kind: "group" as const,
561
+ label: "Alice",
562
+ lastSeenAt: 1700000000000,
563
+ },
564
+ ];
565
+ });
566
+ const ctrl = createGatewayControl({
567
+ gateway: gw as any,
568
+ configIO: io,
569
+ loginSessions: sessions,
570
+ feishuDiscoveryClient: { discoverChats },
571
+ });
572
+
573
+ const ack = await ctrl.handleRecentSenders({
574
+ provider: "feishu",
575
+ loginId: "fsl_discover",
576
+ accountId: "ag_alice",
577
+ timeoutSeconds: 6,
578
+ });
579
+
580
+ expect(ack.ok).toBe(true);
581
+ expect(ack.result).toEqual({
582
+ chats: [
583
+ {
584
+ chatId: "oc_team",
585
+ senderOpenId: "ou_alice",
586
+ kind: "group",
587
+ label: "Alice",
588
+ lastSeenAt: 1700000000000,
589
+ },
590
+ ],
591
+ });
592
+ });
593
+
594
+ it("rejects Feishu chat discovery for a different accountId", async () => {
595
+ const gw = makeFakeGateway();
596
+ const { io } = makeConfigIO(baseCfg());
597
+ const sessions = new LoginSessionStore();
598
+ sessions.create({
599
+ loginId: "fsl_wrong_owner",
600
+ accountId: "ag_alice",
601
+ provider: "feishu",
602
+ qrcode: "DEVICE",
603
+ appId: "cli_feishu_123",
604
+ appSecret: "feishu-secret-1234567890",
605
+ domain: "feishu",
606
+ userOpenId: "ou_alice",
607
+ });
608
+ const discoverChats = vi.fn(async () => []);
609
+ const ctrl = createGatewayControl({
610
+ gateway: gw as any,
611
+ configIO: io,
612
+ loginSessions: sessions,
613
+ feishuDiscoveryClient: { discoverChats },
614
+ });
615
+
616
+ const ack = await ctrl.handleRecentSenders({
617
+ provider: "feishu",
618
+ loginId: "fsl_wrong_owner",
619
+ accountId: "ag_other",
620
+ });
621
+
622
+ expect(ack.ok).toBe(false);
623
+ expect(ack.error?.code).toBe("forbidden");
624
+ expect(discoverChats).not.toHaveBeenCalled();
625
+ });
626
+
627
+ it("rejects Feishu chat discovery before registration is confirmed", async () => {
628
+ const gw = makeFakeGateway();
629
+ const { io } = makeConfigIO(baseCfg());
630
+ const sessions = new LoginSessionStore();
631
+ sessions.create({
632
+ loginId: "fsl_pending",
633
+ accountId: "ag_alice",
634
+ provider: "feishu",
635
+ qrcode: "DEVICE",
636
+ domain: "feishu",
637
+ });
638
+ const ctrl = createGatewayControl({
639
+ gateway: gw as any,
640
+ configIO: io,
641
+ loginSessions: sessions,
642
+ feishuDiscoveryClient: { discoverChats: vi.fn(async () => []) },
643
+ });
644
+
645
+ const ack = await ctrl.handleRecentSenders({
646
+ provider: "feishu",
647
+ loginId: "fsl_pending",
648
+ accountId: "ag_alice",
649
+ });
650
+
651
+ expect(ack.ok).toBe(false);
652
+ expect(ack.error?.code).toBe("login_unconfirmed");
653
+ });
409
654
  });
410
655
 
411
656
  describe("frame schema validation", () => {
@@ -584,6 +829,170 @@ describe("W6: UPDATE rollback on addChannel failure", () => {
584
829
  expect(onDisk.botToken).toBe("old-token:123456789012345");
585
830
  }
586
831
  });
832
+
833
+ it("restores previous Feishu secret/config and re-adds old channel when update addChannel fails", async () => {
834
+ const gw = makeFakeGateway();
835
+ const { state, io } = makeConfigIO({ ...baseCfg(), agents: ["ag_alice", "ag_bob"] });
836
+ const sessions = new LoginSessionStore();
837
+ const ctrl = createGatewayControl({
838
+ gateway: gw as any,
839
+ configIO: io,
840
+ loginSessions: sessions,
841
+ });
842
+ const gwId = uniqId("w6fs");
843
+ trackSecret(gwId);
844
+
845
+ sessions.create({
846
+ loginId: "fsl_w6_old",
847
+ accountId: "ag_alice",
848
+ provider: "feishu",
849
+ appId: "cli_old",
850
+ appSecret: "old-feishu-secret-12345",
851
+ userOpenId: "ou_old",
852
+ domain: "feishu",
853
+ });
854
+ const firstAck = await ctrl.handleUpsert({
855
+ id: gwId,
856
+ type: "feishu",
857
+ accountId: "ag_alice",
858
+ enabled: true,
859
+ loginId: "fsl_w6_old",
860
+ settings: { allowedChatIds: ["oc_old"], domain: "feishu" },
861
+ });
862
+ expect(firstAck.ok).toBe(true);
863
+
864
+ sessions.create({
865
+ loginId: "fsl_w6_new",
866
+ accountId: "ag_bob",
867
+ provider: "feishu",
868
+ appId: "cli_new",
869
+ appSecret: "new-feishu-secret-ABCDE",
870
+ userOpenId: "ou_new",
871
+ domain: "lark",
872
+ });
873
+
874
+ let addCallCount = 0;
875
+ gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
876
+ addCallCount += 1;
877
+ if (addCallCount === 1) throw new Error("simulated feishu update failure");
878
+ gw.channels.set(cfg.id, {
879
+ id: cfg.id,
880
+ status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
881
+ });
882
+ });
883
+
884
+ const updateAck = await ctrl.handleUpsert({
885
+ id: gwId,
886
+ type: "feishu",
887
+ accountId: "ag_bob",
888
+ enabled: true,
889
+ loginId: "fsl_w6_new",
890
+ settings: { allowedChatIds: ["oc_new"], domain: "lark" },
891
+ });
892
+ expect(updateAck.ok).toBe(false);
893
+ expect(updateAck.error?.code).toBe("addChannel_failed");
894
+ expect(addCallCount).toBe(2);
895
+
896
+ const profile = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
897
+ expect(profile).toMatchObject({
898
+ id: gwId,
899
+ type: "feishu",
900
+ appId: "cli_old",
901
+ domain: "feishu",
902
+ userOpenId: "ou_old",
903
+ allowedChatIds: ["oc_old"],
904
+ });
905
+
906
+ const secretPath = trackSecret(gwId);
907
+ const onDisk = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
908
+ expect(onDisk.appSecret).toBe("old-feishu-secret-12345");
909
+ expect(gw.addChannel).toHaveBeenLastCalledWith(
910
+ expect.objectContaining({
911
+ id: gwId,
912
+ type: "feishu",
913
+ accountId: "ag_alice",
914
+ appId: "cli_old",
915
+ domain: "feishu",
916
+ allowedChatIds: ["oc_old"],
917
+ }),
918
+ );
919
+ });
920
+
921
+ it("replaces previous Feishu profile on rollback so failed update fields are removed", async () => {
922
+ const gw = makeFakeGateway();
923
+ const gwId = uniqId("w6fs-replace");
924
+ trackSecret(gwId);
925
+ saveGatewaySecret(gwId, { appSecret: "old-feishu-secret-replace" });
926
+ const { state, io } = makeConfigIO({
927
+ ...baseCfg(),
928
+ agents: ["ag_alice", "ag_bob"],
929
+ thirdPartyGateways: [
930
+ {
931
+ id: gwId,
932
+ type: "feishu",
933
+ accountId: "ag_alice",
934
+ enabled: true,
935
+ appId: "cli_old_replace",
936
+ },
937
+ ],
938
+ });
939
+ const sessions = new LoginSessionStore();
940
+ const ctrl = createGatewayControl({
941
+ gateway: gw as any,
942
+ configIO: io,
943
+ loginSessions: sessions,
944
+ });
945
+
946
+ sessions.create({
947
+ loginId: "fsl_w6_replace_new",
948
+ accountId: "ag_bob",
949
+ provider: "feishu",
950
+ appId: "cli_new_replace",
951
+ appSecret: "new-feishu-secret-replace",
952
+ userOpenId: "ou_new_replace",
953
+ domain: "lark",
954
+ });
955
+
956
+ let addCallCount = 0;
957
+ gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
958
+ addCallCount += 1;
959
+ if (addCallCount === 1) throw new Error("simulated feishu update failure");
960
+ gw.channels.set(cfg.id, {
961
+ id: cfg.id,
962
+ status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
963
+ });
964
+ });
965
+
966
+ const updateAck = await ctrl.handleUpsert({
967
+ id: gwId,
968
+ type: "feishu",
969
+ accountId: "ag_bob",
970
+ enabled: true,
971
+ loginId: "fsl_w6_replace_new",
972
+ settings: {
973
+ allowedSenderIds: ["ou_new_replace"],
974
+ allowedChatIds: ["oc_new_replace"],
975
+ splitAt: 2000,
976
+ domain: "lark",
977
+ },
978
+ });
979
+
980
+ expect(updateAck.ok).toBe(false);
981
+ expect(updateAck.error?.code).toBe("addChannel_failed");
982
+ expect(addCallCount).toBe(2);
983
+ expect(state.cfg.thirdPartyGateways).toEqual([
984
+ {
985
+ id: gwId,
986
+ type: "feishu",
987
+ accountId: "ag_alice",
988
+ enabled: true,
989
+ appId: "cli_old_replace",
990
+ },
991
+ ]);
992
+
993
+ const onDisk = JSON.parse(readFileSync(trackSecret(gwId), "utf8")) as { appSecret?: string };
994
+ expect(onDisk.appSecret).toBe("old-feishu-secret-replace");
995
+ });
587
996
  });
588
997
 
589
998
  describe("list_gateways", () => {
@@ -686,6 +1095,90 @@ describe("gateway_send", () => {
686
1095
  expect(ack.error?.code).toBe("conversation_not_allowed");
687
1096
  expect(gw.sendOutbound).not.toHaveBeenCalled();
688
1097
  });
1098
+
1099
+ it("allows Feishu outbound when allowedChatIds is empty", async () => {
1100
+ const gw = makeFakeGateway();
1101
+ const gwId = uniqId("send-fs-allow-all");
1102
+ const { io } = makeConfigIO({
1103
+ ...baseCfg(),
1104
+ thirdPartyGateways: [
1105
+ {
1106
+ id: gwId,
1107
+ type: "feishu",
1108
+ accountId: "ag_alice",
1109
+ enabled: true,
1110
+ allowedChatIds: [],
1111
+ },
1112
+ ],
1113
+ });
1114
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
1115
+
1116
+ const ack = await ctrl.handleSend({
1117
+ agentId: "ag_alice",
1118
+ gatewayId: gwId,
1119
+ conversationId: "feishu:chat:oc_any",
1120
+ text: "hello",
1121
+ });
1122
+
1123
+ expect(ack.ok).toBe(true);
1124
+ expect(gw.sendOutbound).toHaveBeenCalledOnce();
1125
+ });
1126
+
1127
+ it("allows Feishu outbound when allowedChatIds is omitted", async () => {
1128
+ const gw = makeFakeGateway();
1129
+ const gwId = uniqId("send-fs-allow-omitted");
1130
+ const { io } = makeConfigIO({
1131
+ ...baseCfg(),
1132
+ thirdPartyGateways: [
1133
+ {
1134
+ id: gwId,
1135
+ type: "feishu",
1136
+ accountId: "ag_alice",
1137
+ enabled: true,
1138
+ },
1139
+ ],
1140
+ });
1141
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
1142
+
1143
+ const ack = await ctrl.handleSend({
1144
+ agentId: "ag_alice",
1145
+ gatewayId: gwId,
1146
+ conversationId: "feishu:chat:oc_any",
1147
+ text: "hello",
1148
+ });
1149
+
1150
+ expect(ack.ok).toBe(true);
1151
+ expect(gw.sendOutbound).toHaveBeenCalledOnce();
1152
+ });
1153
+
1154
+ it("denies Telegram outbound when allowedChatIds is empty", async () => {
1155
+ const gw = makeFakeGateway();
1156
+ const gwId = uniqId("send-tg-deny-empty");
1157
+ const { io } = makeConfigIO({
1158
+ ...baseCfg(),
1159
+ thirdPartyGateways: [
1160
+ {
1161
+ id: gwId,
1162
+ type: "telegram",
1163
+ accountId: "ag_alice",
1164
+ enabled: true,
1165
+ allowedChatIds: [],
1166
+ },
1167
+ ],
1168
+ });
1169
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
1170
+
1171
+ const ack = await ctrl.handleSend({
1172
+ agentId: "ag_alice",
1173
+ gatewayId: gwId,
1174
+ conversationId: "telegram:group:-100123",
1175
+ text: "hello",
1176
+ });
1177
+
1178
+ expect(ack.ok).toBe(false);
1179
+ expect(ack.error?.code).toBe("conversation_not_allowed");
1180
+ expect(gw.sendOutbound).not.toHaveBeenCalled();
1181
+ });
689
1182
  });
690
1183
 
691
1184
  describe("W4: handleLoginStatus accountId ownership check", () => {
@@ -305,6 +305,10 @@ describe("createDaemonSystemContextBuilder", () => {
305
305
  expect(typeof out).toBe("string");
306
306
  expect(out).toContain("[BotCord Scene: Owner Chat]");
307
307
  expect(out).toContain("full administrative authority");
308
+ expect(out).toContain("cannot open this machine's local filesystem paths");
309
+ expect(out).toContain("share it as a BotCord attachment or an uploaded BotCord URL");
310
+ expect(out).toContain("Do not use local or relative paths such as `output/card.png`");
311
+ expect(out).toContain("upload/attach the file first");
308
312
  });
309
313
 
310
314
  it("injects the owner-chat scene for dashboard_user_chat regardless of room prefix", () => {
@@ -123,6 +123,56 @@ describe("createBotCordChannel — send()", () => {
123
123
  expect(result.providerMessageId).toBe("m_provider");
124
124
  });
125
125
 
126
+ it("uploads outbound attachments and includes them in sendMessage", async () => {
127
+ const client = makeClient({
128
+ uploadFile: vi.fn().mockResolvedValue({
129
+ original_filename: "xhs-01-cover.png",
130
+ url: "https://hub.test/hub/files/f_1",
131
+ content_type: "image/png",
132
+ size_bytes: 1234,
133
+ }),
134
+ });
135
+ const channel = createBotCordChannel({
136
+ id: "botcord-main",
137
+ accountId: "ag_self",
138
+ agentId: "ag_self",
139
+ client,
140
+ });
141
+ await channel.send({
142
+ message: {
143
+ channel: "botcord",
144
+ accountId: "ag_self",
145
+ conversationId: "rm_oc_1",
146
+ text: "done: output/xhs-01-cover.png",
147
+ attachments: [{
148
+ filePath: "/tmp/work/output/xhs-01-cover.png",
149
+ filename: "xhs-01-cover.png",
150
+ contentType: "image/png",
151
+ sourcePath: "output/xhs-01-cover.png",
152
+ }],
153
+ },
154
+ log: silentLog,
155
+ });
156
+
157
+ expect(client.uploadFile).toHaveBeenCalledWith(
158
+ "/tmp/work/output/xhs-01-cover.png",
159
+ "xhs-01-cover.png",
160
+ "image/png",
161
+ );
162
+ expect(client.sendMessage).toHaveBeenCalledWith(
163
+ "rm_oc_1",
164
+ "done: https://hub.test/hub/files/f_1",
165
+ {
166
+ attachments: [{
167
+ filename: "xhs-01-cover.png",
168
+ url: "https://hub.test/hub/files/f_1",
169
+ content_type: "image/png",
170
+ size_bytes: 1234,
171
+ }],
172
+ },
173
+ );
174
+ });
175
+
126
176
  it("omits topic/replyTo when not provided and returns null when response lacks ids", async () => {
127
177
  const client = makeClient({
128
178
  sendMessage: vi.fn().mockResolvedValue({ queued: true, status: "queued" }),