@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
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.
|
|
@@ -66,6 +68,9 @@ export function createGatewayControl(ctx) {
|
|
|
66
68
|
throw urlErr;
|
|
67
69
|
}
|
|
68
70
|
const cfg = cfgIO.load();
|
|
71
|
+
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
72
|
+
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
73
|
+
const hadExistingProfile = prevProfile !== undefined;
|
|
69
74
|
// accountId must belong to a daemon-bound agent. An empty agent set
|
|
70
75
|
// (no agents provisioned yet) is itself a hard reject — otherwise we
|
|
71
76
|
// would silently accept upserts against a daemon that has nowhere to
|
|
@@ -144,42 +149,62 @@ export function createGatewayControl(ctx) {
|
|
|
144
149
|
else if (params.type === "feishu") {
|
|
145
150
|
const loginId = params.loginId;
|
|
146
151
|
if (!loginId) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
code: "login_account_mismatch",
|
|
167
|
-
message: "feishu login session accountId does not match upsert request",
|
|
168
|
-
},
|
|
169
|
-
};
|
|
152
|
+
if (!prevProfile ||
|
|
153
|
+
prevProfile.type !== "feishu" ||
|
|
154
|
+
prevProfile.accountId !== params.accountId ||
|
|
155
|
+
!prevProfile.appId) {
|
|
156
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
157
|
+
}
|
|
158
|
+
const existing = loadGatewaySecret(params.id);
|
|
159
|
+
if (!existing?.appSecret) {
|
|
160
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
161
|
+
}
|
|
162
|
+
if (params.settings?.domain !== undefined &&
|
|
163
|
+
params.settings.domain !== (prevProfile.domain ?? "feishu")) {
|
|
164
|
+
return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
|
|
165
|
+
}
|
|
166
|
+
secretPayload = { appSecret: existing.appSecret };
|
|
167
|
+
tokenPreviewSource = existing.appSecret;
|
|
168
|
+
feishuAppId = prevProfile.appId;
|
|
169
|
+
feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
|
|
170
|
+
feishuUserOpenId = prevProfile.userOpenId;
|
|
170
171
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
else {
|
|
173
|
+
const resolved = sessions.resolve(loginId);
|
|
174
|
+
if (resolved.state !== "live") {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: resolved.state === "missing"
|
|
178
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
179
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const session = resolved.session;
|
|
183
|
+
if (session.provider !== "feishu") {
|
|
184
|
+
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
185
|
+
}
|
|
186
|
+
if (session.accountId !== params.accountId) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: {
|
|
190
|
+
code: "login_account_mismatch",
|
|
191
|
+
message: "feishu login session accountId does not match upsert request",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!session.appId || !session.appSecret) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
secretPayload = { appSecret: session.appSecret };
|
|
202
|
+
tokenPreviewSource = session.appSecret;
|
|
203
|
+
feishuAppId = session.appId;
|
|
204
|
+
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
205
|
+
feishuUserOpenId = session.userOpenId;
|
|
206
|
+
sessions.update(loginId, { gatewayId: params.id });
|
|
176
207
|
}
|
|
177
|
-
secretPayload = { appSecret: session.appSecret };
|
|
178
|
-
tokenPreviewSource = session.appSecret;
|
|
179
|
-
feishuAppId = session.appId;
|
|
180
|
-
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
181
|
-
feishuUserOpenId = session.userOpenId;
|
|
182
|
-
sessions.update(loginId, { gatewayId: params.id });
|
|
183
208
|
}
|
|
184
209
|
else {
|
|
185
210
|
return badParams(`upsert_gateway: unknown provider "${params.type}"`);
|
|
@@ -187,9 +212,6 @@ export function createGatewayControl(ctx) {
|
|
|
187
212
|
// W3/W6: remember whether a profile already exists for this id BEFORE we
|
|
188
213
|
// write the secret/config. For UPDATE path, capture previous profile +
|
|
189
214
|
// previous secret so addChannel failure can restore prior state.
|
|
190
|
-
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
191
|
-
const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
|
|
192
|
-
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
193
215
|
// W6: load the previous secret for UPDATE rollback BEFORE overwriting.
|
|
194
216
|
const prevSecret = hadExistingProfile
|
|
195
217
|
? loadGatewaySecret(params.id)
|
|
@@ -253,19 +275,24 @@ export function createGatewayControl(ctx) {
|
|
|
253
275
|
}
|
|
254
276
|
try {
|
|
255
277
|
if (prevProfile) {
|
|
256
|
-
cfgIO.save(
|
|
278
|
+
cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
|
|
257
279
|
}
|
|
258
280
|
}
|
|
259
281
|
catch {
|
|
260
282
|
// best-effort
|
|
261
283
|
}
|
|
262
284
|
try {
|
|
263
|
-
if (prevProfile &&
|
|
285
|
+
if (prevProfile &&
|
|
286
|
+
((prevProfile.type === "telegram" && prevSecret?.botToken) ||
|
|
287
|
+
(prevProfile.type === "feishu" && prevSecret?.appSecret))) {
|
|
264
288
|
await ctx.gateway.addChannel(buildChannelConfig({
|
|
265
289
|
...params,
|
|
266
290
|
type: prevProfile.type,
|
|
291
|
+
accountId: prevProfile.accountId,
|
|
267
292
|
enabled: prevProfile.enabled !== false,
|
|
268
|
-
|
|
293
|
+
...(prevProfile.type === "telegram"
|
|
294
|
+
? { secret: { botToken: prevSecret.botToken } }
|
|
295
|
+
: {}),
|
|
269
296
|
settings: {
|
|
270
297
|
baseUrl: prevProfile.baseUrl,
|
|
271
298
|
allowedSenderIds: prevProfile.allowedSenderIds,
|
|
@@ -656,7 +683,7 @@ export function createGatewayControl(ctx) {
|
|
|
656
683
|
if (!isProvider(params.provider)) {
|
|
657
684
|
return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
|
|
658
685
|
}
|
|
659
|
-
if (params.provider !== "wechat") {
|
|
686
|
+
if (params.provider !== "wechat" && params.provider !== "feishu") {
|
|
660
687
|
return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
|
|
661
688
|
}
|
|
662
689
|
if (!params.loginId) {
|
|
@@ -670,12 +697,12 @@ export function createGatewayControl(ctx) {
|
|
|
670
697
|
return {
|
|
671
698
|
ok: false,
|
|
672
699
|
error: resolved.state === "missing"
|
|
673
|
-
? { code: "login_missing", message:
|
|
674
|
-
: { 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` },
|
|
675
702
|
};
|
|
676
703
|
}
|
|
677
704
|
const session = resolved.session;
|
|
678
|
-
if (session.provider !==
|
|
705
|
+
if (session.provider !== params.provider) {
|
|
679
706
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
680
707
|
}
|
|
681
708
|
if (session.accountId !== params.accountId) {
|
|
@@ -687,6 +714,44 @@ export function createGatewayControl(ctx) {
|
|
|
687
714
|
},
|
|
688
715
|
};
|
|
689
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
|
+
}
|
|
690
755
|
if (!session.botToken) {
|
|
691
756
|
return {
|
|
692
757
|
ok: false,
|
|
@@ -854,6 +919,9 @@ function validateOutboundConversation(profile, conversationId) {
|
|
|
854
919
|
},
|
|
855
920
|
};
|
|
856
921
|
}
|
|
922
|
+
if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
857
925
|
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
858
926
|
if (!allowed.has(chatId)) {
|
|
859
927
|
return {
|
|
@@ -929,6 +997,18 @@ function upsertProfileInConfig(cfg, patch) {
|
|
|
929
997
|
}
|
|
930
998
|
return { ...cfg, thirdPartyGateways: list };
|
|
931
999
|
}
|
|
1000
|
+
function replaceProfileInConfig(cfg, profile) {
|
|
1001
|
+
const list = (cfg.thirdPartyGateways ?? []).slice();
|
|
1002
|
+
const idx = list.findIndex((g) => g.id === profile.id);
|
|
1003
|
+
const compact = compactProfile(profile);
|
|
1004
|
+
if (idx >= 0) {
|
|
1005
|
+
list[idx] = compact;
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
list.push(compact);
|
|
1009
|
+
}
|
|
1010
|
+
return { ...cfg, thirdPartyGateways: list };
|
|
1011
|
+
}
|
|
932
1012
|
function compactProfile(p) {
|
|
933
1013
|
const out = {
|
|
934
1014
|
id: p.id,
|
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/
|
|
27
|
-
"@botcord/
|
|
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
|
+
});
|