@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.
- package/dist/gateway/channels/botcord.d.ts +9 -1
- package/dist/gateway/channels/botcord.js +55 -2
- package/dist/gateway/channels/feishu.d.ts +56 -0
- package/dist/gateway/channels/feishu.js +76 -0
- package/dist/gateway/cli-resolver.d.ts +1 -0
- package/dist/gateway/cli-resolver.js +2 -0
- package/dist/gateway/dispatcher.d.ts +20 -0
- package/dist/gateway/dispatcher.js +252 -0
- package/dist/gateway/runtimes/codex.js +1 -0
- package/dist/gateway/runtimes/deepseek-tui.js +1 -0
- package/dist/gateway/runtimes/hermes-agent.js +1 -0
- package/dist/gateway/runtimes/kimi.js +1 -0
- package/dist/gateway/runtimes/ndjson-stream.js +1 -0
- package/dist/gateway/types.d.ts +8 -0
- package/dist/gateway/wait-marker.d.ts +32 -0
- package/dist/gateway/wait-marker.js +96 -0
- package/dist/gateway-control.d.ts +4 -0
- package/dist/gateway-control.js +124 -44
- package/dist/loop-risk.js +2 -0
- package/dist/system-context.js +3 -0
- package/dist/turn-text.js +5 -0
- package/package.json +3 -3
- package/src/__tests__/feishu-channel.test.ts +180 -0
- package/src/__tests__/gateway-control.test.ts +493 -0
- package/src/__tests__/system-context.test.ts +4 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +50 -0
- package/src/gateway/__tests__/dispatcher-park.test.ts +207 -0
- package/src/gateway/__tests__/dispatcher.test.ts +48 -1
- package/src/gateway/__tests__/wait-marker.test.ts +90 -0
- package/src/gateway/channels/botcord.ts +79 -5
- package/src/gateway/channels/feishu.ts +122 -0
- package/src/gateway/cli-resolver.ts +2 -0
- package/src/gateway/dispatcher.ts +292 -0
- package/src/gateway/runtimes/codex.ts +1 -0
- package/src/gateway/runtimes/deepseek-tui.ts +1 -0
- package/src/gateway/runtimes/hermes-agent.ts +1 -0
- package/src/gateway/runtimes/kimi.ts +1 -0
- package/src/gateway/runtimes/ndjson-stream.ts +1 -0
- package/src/gateway/types.ts +8 -0
- package/src/gateway/wait-marker.ts +101 -0
- package/src/gateway-control.ts +150 -48
- package/src/loop-risk.ts +1 -0
- package/src/system-context.ts +3 -0
- 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",
|
|
920
|
-
: await client.sendMessage(message.conversationId,
|
|
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 ?? "";
|