@botcord/daemon 0.2.92 → 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 +44 -4
  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 +121 -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 +59 -5
  42. package/src/loop-risk.ts +1 -0
  43. package/src/system-context.ts +3 -0
  44. package/src/turn-text.ts +5 -0
@@ -285,6 +285,14 @@ export interface RuntimeRunOptions {
285
285
  * unspecified and the bundled CLI falls back to its own default.
286
286
  */
287
287
  hubUrl?: string;
288
+ /**
289
+ * Absolute path the spawned CLI should write a `botcord wait` park marker to,
290
+ * exposed to the subprocess as `BOTCORD_WAIT_FILE`. Scoped per queue by the
291
+ * dispatcher so concurrent group-room turns sharing one workspace don't
292
+ * clobber each other. Unset for non-deferrable rooms (owner-chat / DM /
293
+ * non-group), in which case `botcord wait` is a harmless no-op.
294
+ */
295
+ waitMarkerFile?: string;
288
296
  signal: AbortSignal;
289
297
  extraArgs?: string[];
290
298
  trustLevel: TrustLevel;
@@ -0,0 +1,32 @@
1
+ /** Filename stem for park markers. */
2
+ export declare const WAIT_MARKER_PREFIX = ".botcord-wait";
3
+ /** Legacy unscoped marker name — the CLI's fallback when `BOTCORD_WAIT_FILE`
4
+ * is unset. Never consumed by the dispatcher (which always scopes per queue),
5
+ * so an unscoped write is a harmless no-op. */
6
+ export declare const WAIT_MARKER_FILENAME = ".botcord-wait.json";
7
+ /** Hard ceiling on a single park request (mirrors the CLI clamp). Also used by
8
+ * the dispatcher as the total accumulated-park budget across consecutive
9
+ * re-wakes on one queue. */
10
+ export declare const MAX_WAIT_MS = 30000;
11
+ export interface WaitMarker {
12
+ /** Absolute unix millis the agent wants to be re-woken by (already clamped). */
13
+ deadlineMs: number;
14
+ reason?: string;
15
+ }
16
+ /** Legacy unscoped path under `cwd` (CLI fallback / tests). */
17
+ export declare function waitMarkerPath(cwd: string): string;
18
+ /** Per-queue marker path under the agent workspace. */
19
+ export declare function resolveWaitMarkerPath(cwd: string, queueKey: string): string;
20
+ /** Best-effort delete of any pre-existing marker at `markerPath`. Called before
21
+ * a turn runs so that whatever `botcord wait` writes during this turn is
22
+ * unambiguously from this turn. */
23
+ export declare function clearWaitMarker(markerPath: string): void;
24
+ /**
25
+ * Read + delete the marker a turn may have written via `botcord wait`, and
26
+ * return the validated request (or null). Always removes the file so the next
27
+ * turn starts clean. `now` is injectable for tests.
28
+ *
29
+ * A deadline in the past — or beyond {@link MAX_WAIT_MS} — is clamped; a
30
+ * non-positive remaining wait yields null (treated as "no wait").
31
+ */
32
+ export declare function consumeWaitMarker(markerPath: string, now?: number): WaitMarker | null;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Workspace park-marker transport for the agent-driven `botcord wait` defer.
3
+ *
4
+ * In non-owner BotCord group rooms the dispatcher discards the runtime's final
5
+ * text (the agent replies out-of-band via the `botcord send` CLI → Hub), so a
6
+ * "please re-wake me later" signal cannot ride back on the turn result. Instead
7
+ * the bundled `botcord wait <seconds>` CLI drops a tiny JSON marker into the
8
+ * agent's workspace; the dispatcher reads it at the turn boundary and schedules
9
+ * a re-wake. This keeps the *decision* to wait in the agent (it judges
10
+ * relevance/urgency) while the *timer* lives cheaply in the daemon — no runtime
11
+ * session is held open during the wait.
12
+ *
13
+ * The marker path is scoped per **queue** (channel:agent:room:thread): the
14
+ * dispatcher serializes turns within a queue but NOT across queues that share
15
+ * one agent workspace (`route.cwd`), so two concurrent group-room turns for the
16
+ * same agent would otherwise clobber each other's marker. The dispatcher passes
17
+ * the resolved path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
18
+ *
19
+ * Local daemon-hosted agents only: the marker rides the shared filesystem of
20
+ * `route.cwd`. Cloud (sandboxed) agents need a networked transport — out of
21
+ * scope here.
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ /** Filename stem for park markers. */
26
+ export const WAIT_MARKER_PREFIX = ".botcord-wait";
27
+ /** Legacy unscoped marker name — the CLI's fallback when `BOTCORD_WAIT_FILE`
28
+ * is unset. Never consumed by the dispatcher (which always scopes per queue),
29
+ * so an unscoped write is a harmless no-op. */
30
+ export const WAIT_MARKER_FILENAME = `${WAIT_MARKER_PREFIX}.json`;
31
+ /** Hard ceiling on a single park request (mirrors the CLI clamp). Also used by
32
+ * the dispatcher as the total accumulated-park budget across consecutive
33
+ * re-wakes on one queue. */
34
+ export const MAX_WAIT_MS = 30_000;
35
+ /** Legacy unscoped path under `cwd` (CLI fallback / tests). */
36
+ export function waitMarkerPath(cwd) {
37
+ return path.join(cwd, WAIT_MARKER_FILENAME);
38
+ }
39
+ /** Per-queue marker path under the agent workspace. */
40
+ export function resolveWaitMarkerPath(cwd, queueKey) {
41
+ const safe = queueKey.replace(/[^a-zA-Z0-9._-]/g, "_");
42
+ return path.join(cwd, `${WAIT_MARKER_PREFIX}.${safe}.json`);
43
+ }
44
+ /** Best-effort delete of any pre-existing marker at `markerPath`. Called before
45
+ * a turn runs so that whatever `botcord wait` writes during this turn is
46
+ * unambiguously from this turn. */
47
+ export function clearWaitMarker(markerPath) {
48
+ try {
49
+ fs.rmSync(markerPath, { force: true });
50
+ }
51
+ catch {
52
+ // A leftover marker only costs one wasted park-check next turn — never
53
+ // let cleanup failure abort the dispatch path.
54
+ }
55
+ }
56
+ /**
57
+ * Read + delete the marker a turn may have written via `botcord wait`, and
58
+ * return the validated request (or null). Always removes the file so the next
59
+ * turn starts clean. `now` is injectable for tests.
60
+ *
61
+ * A deadline in the past — or beyond {@link MAX_WAIT_MS} — is clamped; a
62
+ * non-positive remaining wait yields null (treated as "no wait").
63
+ */
64
+ export function consumeWaitMarker(markerPath, now = Date.now()) {
65
+ let raw;
66
+ try {
67
+ raw = fs.readFileSync(markerPath, "utf8");
68
+ }
69
+ catch {
70
+ return null; // no marker (ENOENT) or unreadable
71
+ }
72
+ try {
73
+ fs.rmSync(markerPath, { force: true });
74
+ }
75
+ catch {
76
+ // ignore — the pre-turn clear will catch it next time
77
+ }
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ if (!parsed || typeof parsed !== "object")
86
+ return null;
87
+ const obj = parsed;
88
+ const deadlineMs = typeof obj.deadlineMs === "number" ? obj.deadlineMs : NaN;
89
+ if (!Number.isFinite(deadlineMs))
90
+ return null;
91
+ const clamped = Math.min(deadlineMs, now + MAX_WAIT_MS);
92
+ if (clamped <= now)
93
+ return null;
94
+ const reason = typeof obj.reason === "string" ? obj.reason : undefined;
95
+ return reason !== undefined ? { deadlineMs: clamped, reason } : { deadlineMs: clamped };
96
+ }
@@ -14,6 +14,7 @@ import { type DaemonConfig } from "./config.js";
14
14
  import { LoginSessionStore } from "./gateway/channels/login-session.js";
15
15
  import { getBotQrcode, getQrcodeStatus } from "./gateway/channels/wechat-login.js";
16
16
  import { pollFeishuRegistration, startFeishuRegistration } from "./gateway/channels/feishu-registration.js";
17
+ import { discoverFeishuChats } from "./gateway/channels/feishu.js";
17
18
  import type { FetchLike } from "./gateway/channels/http-types.js";
18
19
  type AckBody = Omit<ControlAck, "id">;
19
20
  type GatewayProvider = "telegram" | "wechat" | "feishu";
@@ -89,6 +90,9 @@ export interface GatewayControlContext {
89
90
  startFeishuRegistration: typeof startFeishuRegistration;
90
91
  pollFeishuRegistration: typeof pollFeishuRegistration;
91
92
  };
93
+ feishuDiscoveryClient?: {
94
+ discoverChats: typeof discoverFeishuChats;
95
+ };
92
96
  /** Override the global fetch — used by `test_gateway` for Telegram getMe. */
93
97
  fetchImpl?: FetchLike;
94
98
  }
@@ -13,6 +13,7 @@ import { deleteGatewaySecret, loadGatewaySecret, saveGatewaySecret, } from "./ga
13
13
  import { LoginSessionStore, maskTokenPreview, mintLoginId, } from "./gateway/channels/login-session.js";
14
14
  import { DEFAULT_WECHAT_BASE_URL, getBotQrcode, getQrcodeStatus, } from "./gateway/channels/wechat-login.js";
15
15
  import { pollFeishuRegistration, startFeishuRegistration, } from "./gateway/channels/feishu-registration.js";
16
+ import { discoverFeishuChats, } from "./gateway/channels/feishu.js";
16
17
  import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
17
18
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
18
19
  import { log as daemonLog } from "./log.js";
@@ -26,6 +27,7 @@ export function createGatewayControl(ctx) {
26
27
  const sessions = ctx.loginSessions ?? new LoginSessionStore();
27
28
  const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
28
29
  const feishuLogin = ctx.feishuLoginClient ?? { startFeishuRegistration, pollFeishuRegistration };
30
+ const feishuDiscovery = ctx.feishuDiscoveryClient ?? { discoverChats: discoverFeishuChats };
29
31
  // W7: validate fetch availability at construction so a missing global is
30
32
  // diagnosed at startup, not during the first control frame. Tests inject
31
33
  // `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
@@ -681,7 +683,7 @@ export function createGatewayControl(ctx) {
681
683
  if (!isProvider(params.provider)) {
682
684
  return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
683
685
  }
684
- if (params.provider !== "wechat") {
686
+ if (params.provider !== "wechat" && params.provider !== "feishu") {
685
687
  return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
686
688
  }
687
689
  if (!params.loginId) {
@@ -695,12 +697,12 @@ export function createGatewayControl(ctx) {
695
697
  return {
696
698
  ok: false,
697
699
  error: resolved.state === "missing"
698
- ? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
699
- : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
700
+ ? { code: "login_missing", message: `${params.provider} login session "${params.loginId}" not found` }
701
+ : { code: "login_expired", message: `${params.provider} login session "${params.loginId}" expired` },
700
702
  };
701
703
  }
702
704
  const session = resolved.session;
703
- if (session.provider !== "wechat") {
705
+ if (session.provider !== params.provider) {
704
706
  return badParams("gateway_recent_senders: provider does not match login session");
705
707
  }
706
708
  if (session.accountId !== params.accountId) {
@@ -712,6 +714,44 @@ export function createGatewayControl(ctx) {
712
714
  },
713
715
  };
714
716
  }
717
+ if (params.provider === "feishu") {
718
+ if (!session.appId || !session.appSecret || !session.userOpenId) {
719
+ return {
720
+ ok: false,
721
+ error: {
722
+ code: "login_unconfirmed",
723
+ message: "feishu login session has no app credentials yet",
724
+ },
725
+ };
726
+ }
727
+ try {
728
+ const chats = await feishuDiscovery.discoverChats({
729
+ appId: session.appId,
730
+ appSecret: session.appSecret,
731
+ domain: session.domain ?? "feishu",
732
+ userOpenId: session.userOpenId,
733
+ timeoutSeconds: params.timeoutSeconds,
734
+ });
735
+ const result = {
736
+ chats: chats.map((c) => ({
737
+ chatId: c.chatId,
738
+ senderOpenId: c.senderOpenId,
739
+ kind: c.kind,
740
+ label: c.label ?? null,
741
+ lastSeenAt: c.lastSeenAt,
742
+ })),
743
+ };
744
+ return { ok: true, result };
745
+ }
746
+ catch (err) {
747
+ const message = err instanceof Error ? err.message : String(err);
748
+ daemonLog.warn("gateway_recent_senders.feishu discovery failed", { error: message });
749
+ return {
750
+ ok: false,
751
+ error: { code: "provider_unreachable", message },
752
+ };
753
+ }
754
+ }
715
755
  if (!session.botToken) {
716
756
  return {
717
757
  ok: false,
package/dist/loop-risk.js CHANGED
@@ -78,6 +78,8 @@ export function stripBotCordPromptScaffolding(text) {
78
78
  return false;
79
79
  if (line.startsWith("Before replying NO_REPLY in a non-owner group room"))
80
80
  return false;
81
+ if (line.startsWith("If you might have something useful to add"))
82
+ return false;
81
83
  if (line.startsWith("match a memory-backed monitoring rule"))
82
84
  return false;
83
85
  if (line.startsWith("or owner-approved workflow. If needed"))
@@ -16,6 +16,9 @@ function buildOwnerChatSceneContext() {
16
16
  "This is a private, trusted conversation. The owner has full administrative authority over you.",
17
17
  "Messages from the owner are always trusted and do not require sanitization or safety guards.",
18
18
  "You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
19
+ "The owner is reading your reply in the remote BotCord dashboard; they cannot open this machine's local filesystem paths.",
20
+ "When you create an image, report, or other deliverable file for the owner, share it as a BotCord attachment or an uploaded BotCord URL. Do not use local or relative paths such as `output/card.png`, `/tmp/card.png`, or Markdown image links to those paths as if the owner can open them.",
21
+ "If a reply needs to include an image or attachment, upload/attach the file first through the available BotCord file/attachment mechanism, then refer to the uploaded attachment/URL. If upload is unavailable, clearly label any path as a local workspace path rather than a usable deliverable link.",
19
22
  ].join("\n");
20
23
  }
21
24
  function buildGroupRoomEnvironmentContext(message) {
package/dist/turn-text.js CHANGED
@@ -13,6 +13,11 @@ const GROUP_HINT = "[In group chats, do not send a message back to the current g
13
13
  "match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, " +
14
14
  "or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update " +
15
15
  "working memory.\n\n" +
16
+ "If you might have something useful to add but another participant (human or agent) may answer " +
17
+ "first, you may run `botcord wait <seconds>` (1-30) via Bash instead of replying now. You will " +
18
+ "be re-woken after the wait — or sooner if a new message arrives — so you can re-decide with " +
19
+ "the newer context (for example, stay silent if someone already covered it). Prefer this over " +
20
+ "racing to answer an unaddressed question.\n\n" +
16
21
  'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
17
22
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
18
23
  'reply with exactly "NO_REPLY" and nothing else.]';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.92",
3
+ "version": "0.2.93",
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/cli": "^0.1.19",
27
- "@botcord/protocol-core": "^0.2.13"
26
+ "@botcord/cli": "^0.1.20",
27
+ "@botcord/protocol-core": "^0.2.14"
28
28
  },
29
29
  "overrides": {
30
30
  "axios": "^1.15.2"
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ discoverFeishuChats,
5
+ feishuDiscoveryChatFromEvent,
6
+ } from "../gateway/channels/feishu.js";
7
+
8
+ describe("feishu chat discovery parser", () => {
9
+ const timeoutAfter = (ms: number, message: string) =>
10
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), ms));
11
+
12
+ it("captures a group chat_id from the registered sender", () => {
13
+ const hit = feishuDiscoveryChatFromEvent(
14
+ {
15
+ sender: { sender_id: { open_id: "ou_alice" } },
16
+ message: {
17
+ message_id: "om_1",
18
+ chat_id: "oc_team",
19
+ chat_type: "group",
20
+ create_time: "1700000000000",
21
+ mentions: [{ id: { open_id: "ou_alice" }, name: "Alice" }],
22
+ },
23
+ },
24
+ "ou_alice",
25
+ () => 1,
26
+ );
27
+
28
+ expect(hit).toEqual({
29
+ chatId: "oc_team",
30
+ senderOpenId: "ou_alice",
31
+ kind: "group",
32
+ label: "Alice",
33
+ lastSeenAt: 1700000000000,
34
+ });
35
+ });
36
+
37
+ it("marks p2p chat_type as direct", () => {
38
+ const hit = feishuDiscoveryChatFromEvent(
39
+ {
40
+ sender: { sender_id: { open_id: "ou_alice" } },
41
+ message: {
42
+ message_id: "om_2",
43
+ chat_id: "oc_direct",
44
+ chat_type: "p2p",
45
+ },
46
+ },
47
+ "ou_alice",
48
+ () => 1700000000001,
49
+ );
50
+
51
+ expect(hit).toMatchObject({
52
+ chatId: "oc_direct",
53
+ senderOpenId: "ou_alice",
54
+ kind: "direct",
55
+ lastSeenAt: 1700000000001,
56
+ });
57
+ });
58
+
59
+ it("ignores messages from a different sender", () => {
60
+ const hit = feishuDiscoveryChatFromEvent(
61
+ {
62
+ sender: { sender_id: { open_id: "ou_bob" } },
63
+ message: {
64
+ message_id: "om_3",
65
+ chat_id: "oc_team",
66
+ chat_type: "group",
67
+ },
68
+ },
69
+ "ou_alice",
70
+ () => 1,
71
+ );
72
+
73
+ expect(hit).toBeNull();
74
+ });
75
+
76
+ it("surfaces temporary discovery websocket start failure", async () => {
77
+ await expect(
78
+ discoverFeishuChats({
79
+ appId: "cli_123",
80
+ appSecret: "secret_123",
81
+ domain: "feishu",
82
+ userOpenId: "ou_alice",
83
+ timeoutSeconds: 0,
84
+ sdkOverride: {
85
+ createDispatcher: () => ({ register: () => {} }),
86
+ createWsClient: () => ({
87
+ start: () => Promise.reject(new Error("ws start failed")),
88
+ close: () => {
89
+ throw new Error("close failed");
90
+ },
91
+ }),
92
+ },
93
+ }),
94
+ ).rejects.toThrow("ws start failed");
95
+ });
96
+
97
+ it("returns empty chats when listen succeeds with no matching message and close throws", async () => {
98
+ const chats = await discoverFeishuChats({
99
+ appId: "cli_123",
100
+ appSecret: "secret_123",
101
+ domain: "feishu",
102
+ userOpenId: "ou_alice",
103
+ timeoutSeconds: 0,
104
+ sdkOverride: {
105
+ createDispatcher: () => ({ register: () => {} }),
106
+ createWsClient: () => ({
107
+ start: () => undefined,
108
+ close: () => {
109
+ throw new Error("close failed");
110
+ },
111
+ }),
112
+ },
113
+ });
114
+
115
+ expect(chats).toEqual([]);
116
+ });
117
+
118
+ it("returns empty chats when listen succeeds with no matching message and close rejects", async () => {
119
+ const chats = await discoverFeishuChats({
120
+ appId: "cli_123",
121
+ appSecret: "secret_123",
122
+ domain: "feishu",
123
+ userOpenId: "ou_alice",
124
+ timeoutSeconds: 0,
125
+ sdkOverride: {
126
+ createDispatcher: () => ({ register: () => {} }),
127
+ createWsClient: () => ({
128
+ start: () => undefined,
129
+ close: () => Promise.reject(new Error("close failed")),
130
+ }),
131
+ },
132
+ });
133
+
134
+ expect(chats).toEqual([]);
135
+ });
136
+
137
+ it("returns empty chats when listen succeeds with no matching message and close never settles", async () => {
138
+ const chats = await Promise.race([
139
+ discoverFeishuChats({
140
+ appId: "cli_123",
141
+ appSecret: "secret_123",
142
+ domain: "feishu",
143
+ userOpenId: "ou_alice",
144
+ timeoutSeconds: 0,
145
+ sdkOverride: {
146
+ createDispatcher: () => ({ register: () => {} }),
147
+ createWsClient: () => ({
148
+ start: () => undefined,
149
+ close: () => new Promise<never>(() => {}),
150
+ }),
151
+ },
152
+ }),
153
+ timeoutAfter(100, "discovery waited for close"),
154
+ ]);
155
+
156
+ expect(chats).toEqual([]);
157
+ });
158
+
159
+ it("surfaces start failure when close never settles", async () => {
160
+ await expect(
161
+ Promise.race([
162
+ discoverFeishuChats({
163
+ appId: "cli_123",
164
+ appSecret: "secret_123",
165
+ domain: "feishu",
166
+ userOpenId: "ou_alice",
167
+ timeoutSeconds: 0,
168
+ sdkOverride: {
169
+ createDispatcher: () => ({ register: () => {} }),
170
+ createWsClient: () => ({
171
+ start: () => Promise.reject(new Error("ws start failed")),
172
+ close: () => new Promise<never>(() => {}),
173
+ }),
174
+ },
175
+ }),
176
+ timeoutAfter(100, "discovery waited for close"),
177
+ ]),
178
+ ).rejects.toThrow("ws start failed");
179
+ });
180
+ });
@@ -530,6 +530,127 @@ describe("gateway_login_start / status", () => {
530
530
  expect(ack.ok).toBe(false);
531
531
  expect(ack.error?.code).toBe("login_unconfirmed");
532
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
+ });
533
654
  });
534
655
 
535
656
  describe("frame schema validation", () => {
@@ -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" }),