@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.
- 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 +44 -4
- 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 +121 -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 +59 -5
- package/src/loop-risk.ts +1 -0
- package/src/system-context.ts +3 -0
- package/src/turn-text.ts +5 -0
package/dist/gateway/types.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/gateway-control.js
CHANGED
|
@@ -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:
|
|
699
|
-
: { code: "login_expired", message:
|
|
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 !==
|
|
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"))
|
package/dist/system-context.js
CHANGED
|
@@ -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.
|
|
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.
|
|
27
|
-
"@botcord/protocol-core": "^0.2.
|
|
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" }),
|