@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Integration tests for the agent-driven `botcord wait` park / re-wake path.
3
+ *
4
+ * A group-room turn can write a park marker (here simulated via the fake
5
+ * runtime's `observeRun`, standing in for the `botcord wait` CLI writing to
6
+ * `BOTCORD_WAIT_FILE` = `opts.waitMarkerFile`). The dispatcher reads it at the
7
+ * turn boundary and re-dispatches the same message after the (clamped) wait —
8
+ * unless a new message arrives first, or the per-queue caps are hit.
9
+ */
10
+ import { writeFileSync } from "node:fs";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import path from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
16
+ import { SessionStore } from "../session-store.js";
17
+ import { WAIT_MARKER_FILENAME } from "../wait-marker.js";
18
+ import type {
19
+ ChannelAdapter,
20
+ ChannelSendContext,
21
+ ChannelSendResult,
22
+ GatewayConfig,
23
+ GatewayInboundEnvelope,
24
+ GatewayInboundMessage,
25
+ RuntimeAdapter,
26
+ RuntimeRunOptions,
27
+ RuntimeRunResult,
28
+ } from "../types.js";
29
+ import type { GatewayLogger } from "../log.js";
30
+
31
+ function silentLogger(): GatewayLogger {
32
+ return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
33
+ }
34
+
35
+ class FakeChannel implements ChannelAdapter {
36
+ readonly id = "botcord";
37
+ readonly type = "botcord";
38
+ readonly sends: ChannelSendContext[] = [];
39
+ async start(): Promise<void> {}
40
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
41
+ this.sends.push(ctx);
42
+ return {};
43
+ }
44
+ }
45
+
46
+ class FakeRuntime implements RuntimeAdapter {
47
+ readonly id = "claude-code";
48
+ readonly calls: RuntimeRunOptions[] = [];
49
+ constructor(private readonly observeRun?: (opts: RuntimeRunOptions, callNo: number) => void) {}
50
+ async run(options: RuntimeRunOptions): Promise<RuntimeRunResult> {
51
+ this.calls.push(options);
52
+ this.observeRun?.(options, this.calls.length);
53
+ return { text: "NO_REPLY", newSessionId: "" };
54
+ }
55
+ }
56
+
57
+ /** Simulate `botcord wait <s>`: write a park marker to the path the dispatcher
58
+ * handed the subprocess via `BOTCORD_WAIT_FILE` (`opts.waitMarkerFile`). Falls
59
+ * back to the legacy cwd path when unset, so non-eligible rooms still "write"
60
+ * somewhere the dispatcher will never consume. */
61
+ function writeMarker(opts: RuntimeRunOptions, deadlineFromNowMs: number): void {
62
+ const target = opts.waitMarkerFile ?? path.join(opts.cwd, WAIT_MARKER_FILENAME);
63
+ writeFileSync(target, JSON.stringify({ deadlineMs: Date.now() + deadlineFromNowMs }), "utf8");
64
+ }
65
+
66
+ const GROUP_CONVO = { id: "rm_grp1", kind: "group" as const };
67
+ const OWNER_CONVO = { id: "rm_oc_1", kind: "group" as const };
68
+ const DM_CONVO = { id: "rm_dm_1", kind: "direct" as const };
69
+
70
+ function makeEnvelope(partial: Partial<GatewayInboundMessage> = {}): GatewayInboundEnvelope {
71
+ return {
72
+ message: {
73
+ id: partial.id ?? "hub_msg_1",
74
+ channel: "botcord",
75
+ accountId: "ag_me",
76
+ conversation: partial.conversation ?? GROUP_CONVO,
77
+ sender: partial.sender ?? { id: "ag_peer", name: "peer", kind: "agent" },
78
+ text: partial.text ?? "anyone know how to fix this?",
79
+ raw: {},
80
+ replyTo: null,
81
+ receivedAt: Date.now(),
82
+ },
83
+ };
84
+ }
85
+
86
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
87
+
88
+ describe("Dispatcher — botcord wait park/re-wake", () => {
89
+ let cwd: string;
90
+ let storeDir: string;
91
+
92
+ beforeEach(async () => {
93
+ cwd = await mkdtemp(path.join(tmpdir(), "park-cwd-"));
94
+ storeDir = await mkdtemp(path.join(tmpdir(), "park-store-"));
95
+ });
96
+ afterEach(async () => {
97
+ vi.useRealTimers();
98
+ await rm(cwd, { recursive: true, force: true });
99
+ await rm(storeDir, { recursive: true, force: true });
100
+ });
101
+
102
+ async function scaffold(runtime: FakeRuntime) {
103
+ const store = new SessionStore({ path: path.join(storeDir, "sessions.json") });
104
+ await store.load();
105
+ const channel = new FakeChannel();
106
+ const config: GatewayConfig = {
107
+ channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
108
+ defaultRoute: { runtime: "claude-code", cwd },
109
+ routes: [],
110
+ };
111
+ const dispatcher = new Dispatcher({
112
+ config,
113
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
114
+ runtime: (() => runtime) as RuntimeFactory,
115
+ sessionStore: store,
116
+ log: silentLogger(),
117
+ });
118
+ return { dispatcher };
119
+ }
120
+
121
+ it("re-wakes a group-room turn after the marker deadline", async () => {
122
+ const runtime = new FakeRuntime((opts, callNo) => {
123
+ if (callNo === 1) writeMarker(opts, 40);
124
+ });
125
+ const { dispatcher } = await scaffold(runtime);
126
+
127
+ await dispatcher.handle(makeEnvelope());
128
+ expect(runtime.calls.length).toBe(1);
129
+ expect(runtime.calls[0]!.waitMarkerFile).toBeTruthy(); // env wired for group room
130
+
131
+ await sleep(140);
132
+ expect(runtime.calls.length).toBe(2); // original + one re-wake
133
+ });
134
+
135
+ it("a new inbound during the park cancels the scheduled re-wake", async () => {
136
+ const runtime = new FakeRuntime((opts, callNo) => {
137
+ if (callNo === 1) writeMarker(opts, 300);
138
+ });
139
+ const { dispatcher } = await scaffold(runtime);
140
+
141
+ await dispatcher.handle(makeEnvelope({ id: "m1" }));
142
+ expect(runtime.calls.length).toBe(1);
143
+ await dispatcher.handle(makeEnvelope({ id: "m2", text: "never mind, solved it" }));
144
+ expect(runtime.calls.length).toBe(2);
145
+
146
+ await sleep(380);
147
+ expect(runtime.calls.length).toBe(2); // no phantom third turn
148
+ });
149
+
150
+ it("stops re-waking after MAX_PARKS consecutive parks", async () => {
151
+ const runtime = new FakeRuntime((opts) => writeMarker(opts, 30));
152
+ const { dispatcher } = await scaffold(runtime);
153
+
154
+ await dispatcher.handle(makeEnvelope());
155
+ await sleep(360);
156
+ expect(runtime.calls.length).toBe(4); // 1 original + MAX_PARKS (3) re-wakes
157
+ });
158
+
159
+ it("isolates concurrent group-room turns for the same agent/cwd", async () => {
160
+ vi.useFakeTimers();
161
+ // Two different group rooms → two queues → two markers under one workspace.
162
+ // Each parks once; neither clobbers the other.
163
+ const parked = new Set<string>();
164
+ const runtime = new FakeRuntime((opts) => {
165
+ const room = String(opts.context?.roomId ?? "");
166
+ if (!parked.has(room)) {
167
+ parked.add(room);
168
+ writeMarker(opts, 40);
169
+ }
170
+ });
171
+ const { dispatcher } = await scaffold(runtime);
172
+
173
+ await Promise.all([
174
+ dispatcher.handle(makeEnvelope({ id: "a1", conversation: { id: "rm_gA", kind: "group" } })),
175
+ dispatcher.handle(makeEnvelope({ id: "b1", conversation: { id: "rm_gB", kind: "group" } })),
176
+ ]);
177
+ expect(runtime.calls.length).toBe(2);
178
+ // Distinct per-queue marker paths.
179
+ expect(runtime.calls[0]!.waitMarkerFile).not.toBe(runtime.calls[1]!.waitMarkerFile);
180
+
181
+ await vi.advanceTimersByTimeAsync(160);
182
+ // Both rooms re-woke exactly once → 4 total, not 2 (one swallowed) or 3.
183
+ expect(runtime.calls.length).toBe(4);
184
+ const reWokenRooms = runtime.calls.map((c) => c.context?.roomId).sort();
185
+ expect(reWokenRooms).toEqual(["rm_gA", "rm_gA", "rm_gB", "rm_gB"]);
186
+ });
187
+
188
+ it("ignores the marker in an owner-chat room", async () => {
189
+ const runtime = new FakeRuntime((opts) => writeMarker(opts, 40));
190
+ const { dispatcher } = await scaffold(runtime);
191
+
192
+ await dispatcher.handle(makeEnvelope({ conversation: OWNER_CONVO }));
193
+ expect(runtime.calls[0]!.waitMarkerFile).toBeUndefined(); // not park-eligible
194
+ await sleep(140);
195
+ expect(runtime.calls.length).toBe(1);
196
+ });
197
+
198
+ it("ignores the marker in a non-group (DM) room", async () => {
199
+ const runtime = new FakeRuntime((opts) => writeMarker(opts, 40));
200
+ const { dispatcher } = await scaffold(runtime);
201
+
202
+ await dispatcher.handle(makeEnvelope({ conversation: DM_CONVO }));
203
+ expect(runtime.calls[0]!.waitMarkerFile).toBeUndefined();
204
+ await sleep(140);
205
+ expect(runtime.calls.length).toBe(1);
206
+ });
207
+ });
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -2221,6 +2221,53 @@ describe("Dispatcher", () => {
2221
2221
  // Owner-chat reply gating
2222
2222
  // ─────────────────────────────────────────────────────────────────────
2223
2223
 
2224
+ it("owner-chat reply auto-attaches generated local artifacts mentioned by relative path", async () => {
2225
+ const workDir = await mkdtemp(path.join(tmpdir(), "dispatcher-artifacts-"));
2226
+ tempDirs.push(workDir);
2227
+ await mkdir(path.join(workDir, "output"), { recursive: true });
2228
+ await mkdir(path.join(workDir, "social-card-botcord-agent-hub"), { recursive: true });
2229
+ await writeFile(path.join(workDir, "output", "xhs-01-cover.png"), "png-bytes");
2230
+ await writeFile(path.join(workDir, "social-card-botcord-agent-hub", "index.html"), "<html></html>");
2231
+
2232
+ const runtime = new FakeRuntime({
2233
+ reply: [
2234
+ "成品路径:",
2235
+ "",
2236
+ "output/xhs-01-cover.png",
2237
+ "",
2238
+ "项目文件在:",
2239
+ "",
2240
+ "social-card-botcord-agent-hub/index.html",
2241
+ ].join("\n"),
2242
+ newSessionId: "sid-1",
2243
+ });
2244
+ const { dispatcher, channel } = await scaffold({
2245
+ config: baseConfig({ defaultRoute: { runtime: "claude-code", cwd: workDir } }),
2246
+ runtimeFactory: () => runtime,
2247
+ });
2248
+
2249
+ await dispatcher.handle(makeEnvelope({ conversation: { id: "rm_oc_1", kind: "direct" } }));
2250
+
2251
+ const realWorkDir = await realpath(workDir);
2252
+ expect(channel.sends.length).toBe(1);
2253
+ expect(channel.sends[0].message.attachments).toEqual([
2254
+ {
2255
+ filePath: path.join(realWorkDir, "output", "xhs-01-cover.png"),
2256
+ filename: "xhs-01-cover.png",
2257
+ contentType: "image/png",
2258
+ sourcePath: "output/xhs-01-cover.png",
2259
+ kind: "image",
2260
+ },
2261
+ {
2262
+ filePath: path.join(realWorkDir, "social-card-botcord-agent-hub", "index.html"),
2263
+ filename: "index.html",
2264
+ contentType: "text/html",
2265
+ sourcePath: "social-card-botcord-agent-hub/index.html",
2266
+ kind: "file",
2267
+ },
2268
+ ]);
2269
+ });
2270
+
2224
2271
  it("non-owner-chat room: discards result.text, agent must use botcord_send", async () => {
2225
2272
  const runtime = new FakeRuntime({ reply: "would-be-reply", newSessionId: "sid-1" });
2226
2273
  const { dispatcher, channel, store } = await scaffold({
@@ -0,0 +1,90 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import {
7
+ WAIT_MARKER_FILENAME,
8
+ MAX_WAIT_MS,
9
+ waitMarkerPath,
10
+ resolveWaitMarkerPath,
11
+ clearWaitMarker,
12
+ consumeWaitMarker,
13
+ } from "../wait-marker.js";
14
+
15
+ describe("wait-marker", () => {
16
+ let dir: string;
17
+ let marker: string;
18
+
19
+ beforeEach(async () => {
20
+ dir = await mkdtemp(path.join(tmpdir(), "wait-marker-"));
21
+ marker = waitMarkerPath(dir);
22
+ });
23
+ afterEach(async () => {
24
+ await rm(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ const write = (obj: unknown, at: string = marker) =>
28
+ writeFile(at, JSON.stringify(obj), "utf8");
29
+
30
+ it("returns null when no marker exists", () => {
31
+ expect(consumeWaitMarker(marker)).toBeNull();
32
+ });
33
+
34
+ it("reads a valid marker, clamps to the deadline, and deletes the file", async () => {
35
+ const now = 1_000_000;
36
+ await write({ deadlineMs: now + 8_000, seconds: 8 });
37
+ expect(consumeWaitMarker(marker, now)).toEqual({ deadlineMs: now + 8_000 });
38
+ expect(existsSync(marker)).toBe(false);
39
+ });
40
+
41
+ it("preserves a reason string when present", async () => {
42
+ const now = 1_000_000;
43
+ await write({ deadlineMs: now + 5_000, reason: "letting alice answer" });
44
+ expect(consumeWaitMarker(marker, now)).toEqual({
45
+ deadlineMs: now + 5_000,
46
+ reason: "letting alice answer",
47
+ });
48
+ });
49
+
50
+ it("clamps a deadline beyond MAX_WAIT_MS down to now + MAX_WAIT_MS", async () => {
51
+ const now = 1_000_000;
52
+ await write({ deadlineMs: now + 10 * MAX_WAIT_MS });
53
+ expect(consumeWaitMarker(marker, now)).toEqual({ deadlineMs: now + MAX_WAIT_MS });
54
+ });
55
+
56
+ it("returns null for a past deadline (and still deletes the file)", async () => {
57
+ const now = 1_000_000;
58
+ await write({ deadlineMs: now - 1 });
59
+ expect(consumeWaitMarker(marker, now)).toBeNull();
60
+ expect(existsSync(marker)).toBe(false);
61
+ });
62
+
63
+ it("returns null for malformed / non-numeric markers", async () => {
64
+ await write({ nope: true });
65
+ expect(consumeWaitMarker(marker)).toBeNull();
66
+ await writeFile(marker, "{ not json", "utf8");
67
+ expect(consumeWaitMarker(marker)).toBeNull();
68
+ });
69
+
70
+ it("clearWaitMarker removes a stale marker and is a no-op when absent", async () => {
71
+ await write({ deadlineMs: Date.now() + 5_000 });
72
+ clearWaitMarker(marker);
73
+ expect(existsSync(marker)).toBe(false);
74
+ expect(() => clearWaitMarker(marker)).not.toThrow();
75
+ });
76
+
77
+ it("scopes the path per queue and sanitizes the queue key", () => {
78
+ const p = resolveWaitMarkerPath(dir, "botcord:ag_me:rm_g1:tp_x");
79
+ expect(p).toBe(path.join(dir, ".botcord-wait.botcord_ag_me_rm_g1_tp_x.json"));
80
+ // Distinct queues → distinct files (the core of the concurrency fix).
81
+ expect(resolveWaitMarkerPath(dir, "botcord:ag_me:rm_g1:")).not.toBe(
82
+ resolveWaitMarkerPath(dir, "botcord:ag_me:rm_g2:"),
83
+ );
84
+ });
85
+
86
+ it("uses the documented legacy filename", () => {
87
+ expect(WAIT_MARKER_FILENAME).toBe(".botcord-wait.json");
88
+ expect(waitMarkerPath(dir)).toBe(path.join(dir, ".botcord-wait.json"));
89
+ });
90
+ });
@@ -1,3 +1,4 @@
1
+ import { basename } from "node:path";
1
2
  import WebSocket from "ws";
2
3
  import {
3
4
  BotCordClient,
@@ -6,6 +7,7 @@ import {
6
7
  loadStoredCredentials,
7
8
  updateCredentialsToken,
8
9
  type InboxMessage,
10
+ type MessageAttachment,
9
11
  } from "@botcord/protocol-core";
10
12
  import type {
11
13
  ChannelAdapter,
@@ -57,16 +59,26 @@ export interface BotCordChannelClient {
57
59
  roomId?: string;
58
60
  }): Promise<{ messages: InboxMessage[]; count: number; has_more: boolean }>;
59
61
  ackMessages(messageIds: string[]): Promise<void>;
62
+ uploadFile?(
63
+ filePath: string,
64
+ filename: string,
65
+ contentType?: string,
66
+ ): Promise<{
67
+ original_filename: string;
68
+ url: string;
69
+ content_type?: string;
70
+ size_bytes?: number;
71
+ }>;
60
72
  sendMessage(
61
73
  to: string,
62
74
  text: string,
63
- options?: { replyTo?: string; topic?: string },
75
+ options?: { replyTo?: string; topic?: string; attachments?: MessageAttachment[] },
64
76
  ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
65
77
  sendTypedMessage?(
66
78
  to: string,
67
79
  type: "result" | "error",
68
80
  text: string,
69
- options?: { replyTo?: string; topic?: string },
81
+ options?: { replyTo?: string; topic?: string; attachments?: MessageAttachment[] },
70
82
  ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
71
83
  getHubUrl(): string;
72
84
  onTokenRefresh?: (token: string, expiresAt: number) => void;
@@ -118,6 +130,65 @@ function isUnclaimedAgentError(err: unknown): boolean {
118
130
  );
119
131
  }
120
132
 
133
+ async function uploadOutboundAttachments(
134
+ client: BotCordChannelClient,
135
+ attachments: NonNullable<ChannelSendContext["message"]["attachments"]>,
136
+ log: GatewayLogger,
137
+ ): Promise<{ attachments: MessageAttachment[]; replacements: Array<{ sourcePath: string; url: string }> }> {
138
+ if (attachments.length === 0) return { attachments: [], replacements: [] };
139
+ if (!client.uploadFile) {
140
+ log.warn("botcord send: outbound attachments skipped because uploadFile is unavailable", {
141
+ count: attachments.length,
142
+ });
143
+ return { attachments: [], replacements: [] };
144
+ }
145
+
146
+ const uploaded: MessageAttachment[] = [];
147
+ const replacements: Array<{ sourcePath: string; url: string }> = [];
148
+ for (const attachment of attachments) {
149
+ if (!attachment.filePath) {
150
+ log.warn("botcord send: attachment without filePath skipped", {
151
+ filename: attachment.filename ?? null,
152
+ });
153
+ continue;
154
+ }
155
+ try {
156
+ const resp = await client.uploadFile(
157
+ attachment.filePath,
158
+ attachment.filename ?? basename(attachment.filePath),
159
+ attachment.contentType,
160
+ );
161
+ if (attachment.sourcePath) {
162
+ replacements.push({ sourcePath: attachment.sourcePath, url: resp.url });
163
+ }
164
+ uploaded.push({
165
+ filename: resp.original_filename,
166
+ url: resp.url,
167
+ ...(resp.content_type ? { content_type: resp.content_type } : {}),
168
+ ...(typeof resp.size_bytes === "number" ? { size_bytes: resp.size_bytes } : {}),
169
+ });
170
+ } catch (err) {
171
+ log.warn("botcord send: attachment upload failed; continuing without it", {
172
+ filename: attachment.filename ?? attachment.filePath,
173
+ error: err instanceof Error ? err.message : String(err),
174
+ });
175
+ }
176
+ }
177
+ return { attachments: uploaded, replacements };
178
+ }
179
+
180
+ function rewriteUploadedAttachmentPaths(
181
+ text: string,
182
+ replacements: Array<{ sourcePath: string; url: string }>,
183
+ ): string {
184
+ let out = text;
185
+ for (const { sourcePath, url } of replacements) {
186
+ if (!sourcePath || !url) continue;
187
+ out = out.replaceAll(sourcePath, url);
188
+ }
189
+ return out;
190
+ }
191
+
121
192
  /** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
122
193
  function defaultClientFactory(input: {
123
194
  agentId: string;
@@ -911,13 +982,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
911
982
  async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
912
983
  const client = ensureClient();
913
984
  const { message } = ctx;
914
- const options: { replyTo?: string; topic?: string } = {};
985
+ const options: { replyTo?: string; topic?: string; attachments?: MessageAttachment[] } = {};
915
986
  if (message.replyTo) options.replyTo = message.replyTo;
916
987
  if (message.threadId) options.topic = message.threadId;
988
+ const upload = await uploadOutboundAttachments(client, message.attachments ?? [], ctx.log);
989
+ if (upload.attachments.length > 0) options.attachments = upload.attachments;
990
+ const text = rewriteUploadedAttachmentPaths(message.text, upload.replacements);
917
991
  const resp =
918
992
  message.type === "error" && client.sendTypedMessage
919
- ? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
920
- : await client.sendMessage(message.conversationId, message.text, options);
993
+ ? await client.sendTypedMessage(message.conversationId, "error", text, options)
994
+ : await client.sendMessage(message.conversationId, text, options);
921
995
  const providerMessageId =
922
996
  (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
923
997
  (resp && typeof (resp as { message_id?: unknown }).message_id === "string"
@@ -70,6 +70,31 @@ interface FeishuMessageEvent {
70
70
  message?: FeishuEventMessage;
71
71
  }
72
72
 
73
+ export interface FeishuDiscoveredChat {
74
+ chatId: string;
75
+ senderOpenId: string;
76
+ kind: "direct" | "group";
77
+ label?: string | null;
78
+ lastSeenAt: number;
79
+ }
80
+
81
+ export interface FeishuChatDiscoveryOptions {
82
+ appId: string;
83
+ appSecret: string;
84
+ domain?: FeishuDomain;
85
+ userOpenId: string;
86
+ timeoutSeconds?: number;
87
+ sdkOverride?: {
88
+ createWsClient(args: Record<string, unknown>): {
89
+ start(opts: unknown): unknown;
90
+ close(opts?: unknown): unknown;
91
+ };
92
+ createDispatcher(): {
93
+ register(handlers: Record<string, (data: unknown) => unknown>): void;
94
+ };
95
+ };
96
+ }
97
+
73
98
  interface FeishuProviderState {
74
99
  seenMessageIds?: Record<string, number>;
75
100
  }
@@ -132,6 +157,103 @@ function senderLabel(event: FeishuMessageEvent): string | undefined {
132
157
  return typeof hit?.name === "string" && hit.name ? hit.name : undefined;
133
158
  }
134
159
 
160
+ export function feishuDiscoveryChatFromEvent(
161
+ event: FeishuMessageEvent,
162
+ allowedSenderOpenId: string,
163
+ now: () => number = () => Date.now(),
164
+ ): FeishuDiscoveredChat | null {
165
+ const message = event.message;
166
+ const senderOpenId = event.sender?.sender_id?.open_id;
167
+ const chatId = message?.chat_id;
168
+ if (!message || !senderOpenId || !chatId) return null;
169
+ if (senderOpenId !== allowedSenderOpenId) return null;
170
+ const chatType = message.chat_type ?? "";
171
+ const kind: "direct" | "group" = chatType === "p2p" ? "direct" : "group";
172
+ const label = senderLabel(event) ?? null;
173
+ return {
174
+ chatId,
175
+ senderOpenId,
176
+ kind,
177
+ label,
178
+ lastSeenAt: Number(message.create_time) || now(),
179
+ };
180
+ }
181
+
182
+ export async function discoverFeishuChats(
183
+ opts: FeishuChatDiscoveryOptions,
184
+ ): Promise<FeishuDiscoveredChat[]> {
185
+ const timeoutSeconds =
186
+ typeof opts.timeoutSeconds === "number"
187
+ ? Math.min(Math.max(Math.floor(opts.timeoutSeconds), 0), 10)
188
+ : 0;
189
+ const chats = new Map<string, FeishuDiscoveredChat>();
190
+ const sdk = Lark as unknown as {
191
+ EventDispatcher: new (args?: Record<string, unknown>) => {
192
+ register(handlers: Record<string, (data: unknown) => unknown>): void;
193
+ };
194
+ WSClient: new (args: Record<string, unknown>) => {
195
+ start(opts: unknown): unknown;
196
+ close(opts?: unknown): unknown;
197
+ };
198
+ LoggerLevel?: { info?: unknown };
199
+ };
200
+ const dispatcher = opts.sdkOverride
201
+ ? opts.sdkOverride.createDispatcher()
202
+ : new sdk.EventDispatcher({});
203
+ dispatcher.register({
204
+ "im.message.receive_v1": (data: unknown) => {
205
+ const discovered = feishuDiscoveryChatFromEvent(
206
+ data as FeishuMessageEvent,
207
+ opts.userOpenId,
208
+ );
209
+ if (!discovered) return;
210
+ const previous = chats.get(discovered.chatId);
211
+ chats.set(discovered.chatId, {
212
+ ...previous,
213
+ ...discovered,
214
+ label: discovered.label ?? previous?.label ?? null,
215
+ lastSeenAt: Math.max(previous?.lastSeenAt ?? 0, discovered.lastSeenAt),
216
+ });
217
+ },
218
+ });
219
+ const wsClientArgs = {
220
+ appId: opts.appId,
221
+ appSecret: opts.appSecret,
222
+ domain: sdkDomain(opts.domain),
223
+ loggerLevel: sdk.LoggerLevel?.info,
224
+ };
225
+ const wsClient = opts.sdkOverride
226
+ ? opts.sdkOverride.createWsClient(wsClientArgs)
227
+ : new sdk.WSClient(wsClientArgs);
228
+ try {
229
+ const startFailure = Promise.resolve()
230
+ .then(() => wsClient.start({ eventDispatcher: dispatcher }))
231
+ .then(
232
+ () => new Promise<never>(() => {}),
233
+ (err) => Promise.reject(err),
234
+ );
235
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
236
+ await Promise.race([startFailure, delay(0)]);
237
+ await Promise.race([startFailure, delay(timeoutSeconds * 1000)]);
238
+ } finally {
239
+ try {
240
+ const closeResult = wsClient.close({ force: true });
241
+ if (
242
+ closeResult &&
243
+ (typeof closeResult === "object" || typeof closeResult === "function") &&
244
+ typeof (closeResult as PromiseLike<unknown>).then === "function"
245
+ ) {
246
+ void Promise.resolve(closeResult).catch(() => {
247
+ // best effort
248
+ });
249
+ }
250
+ } catch {
251
+ // best effort
252
+ }
253
+ }
254
+ return [...chats.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
255
+ }
256
+
135
257
  export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter {
136
258
  const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
137
259
  const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map(String));
@@ -77,10 +77,12 @@ export function buildCliEnv(opts: {
77
77
  hubUrl?: string;
78
78
  accountId?: string;
79
79
  basePath?: string | undefined;
80
+ waitMarkerFile?: string;
80
81
  }): NodeJS.ProcessEnv {
81
82
  const env: NodeJS.ProcessEnv = {};
82
83
  if (opts.hubUrl) env.BOTCORD_HUB = opts.hubUrl;
83
84
  if (opts.accountId) env.BOTCORD_AGENT_ID = opts.accountId;
85
+ if (opts.waitMarkerFile) env.BOTCORD_WAIT_FILE = opts.waitMarkerFile;
84
86
  const cli = resolveBundledCliBin();
85
87
  if (cli) {
86
88
  const existing = opts.basePath ?? "";