@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
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
|
|
3
5
|
import type { GatewayLogger } from "./log.js";
|
|
4
6
|
import { looksLikeRuntimeAuthFailure } from "./runtime-errors.js";
|
|
5
7
|
import { resolveRoute } from "./router.js";
|
|
6
8
|
import { sessionKey, type SessionStore } from "./session-store.js";
|
|
9
|
+
import { clearWaitMarker, consumeWaitMarker, resolveWaitMarkerPath, MAX_WAIT_MS } from "./wait-marker.js";
|
|
7
10
|
import {
|
|
8
11
|
truncateTextField,
|
|
9
12
|
type DeliveryStatus,
|
|
@@ -50,6 +53,11 @@ const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password
|
|
|
50
53
|
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
51
54
|
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
52
55
|
|
|
56
|
+
/** Max consecutive agent-driven `botcord wait` parks on one queue before the
|
|
57
|
+
* next turn is forced to produce a real decision. Total accumulated wait is
|
|
58
|
+
* separately bounded by {@link MAX_WAIT_MS}. */
|
|
59
|
+
const MAX_PARKS = 3;
|
|
60
|
+
|
|
53
61
|
/**
|
|
54
62
|
* Soft cap on the total characters across raw.batch members in a merged
|
|
55
63
|
* turn. When exceeded, oldest entries are dropped (with a warn log) so the
|
|
@@ -74,6 +82,31 @@ const TYPING_REFRESH_MS = 4000;
|
|
|
74
82
|
|
|
75
83
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
76
84
|
const TYPING_RECENCY_CAP = 1024;
|
|
85
|
+
const AUTO_ATTACHMENT_LIMIT = 10;
|
|
86
|
+
const AUTO_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
|
87
|
+
const AUTO_ATTACHMENT_EXTENSIONS = new Set([
|
|
88
|
+
".avif",
|
|
89
|
+
".bmp",
|
|
90
|
+
".csv",
|
|
91
|
+
".doc",
|
|
92
|
+
".docx",
|
|
93
|
+
".gif",
|
|
94
|
+
".htm",
|
|
95
|
+
".html",
|
|
96
|
+
".jpeg",
|
|
97
|
+
".jpg",
|
|
98
|
+
".pdf",
|
|
99
|
+
".png",
|
|
100
|
+
".ppt",
|
|
101
|
+
".pptx",
|
|
102
|
+
".svg",
|
|
103
|
+
".webp",
|
|
104
|
+
".xls",
|
|
105
|
+
".xlsx",
|
|
106
|
+
".zip",
|
|
107
|
+
]);
|
|
108
|
+
const REPLY_LOCAL_PATH_RE =
|
|
109
|
+
/(^|[\s([{"'`])((?:\/|\.{1,2}\/)?(?:[\w@+.-]+\/)+[\w@+.-]+\.(?:avif|bmp|csv|docx?|gif|html?|jpe?g|pdf|png|pptx?|svg|webp|xlsx?|zip))(?=$|[\s)\]}"'`,.!?:;])/gi;
|
|
77
110
|
|
|
78
111
|
function transcriptBlocksVerbose(): boolean {
|
|
79
112
|
return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
|
|
@@ -403,6 +436,19 @@ interface QueueState {
|
|
|
403
436
|
serialBuffer: BufferedSerialEntry[];
|
|
404
437
|
/** True when the serial-drain worker is actively running (or about to). */
|
|
405
438
|
serialWorkerActive: boolean;
|
|
439
|
+
/**
|
|
440
|
+
* Active re-wake timer scheduled when a group-room turn ran `botcord wait`.
|
|
441
|
+
* Null when no park is pending. An external arrival on this queue clears it
|
|
442
|
+
* (the new message supersedes the scheduled re-wake); the timer callback
|
|
443
|
+
* nulls it itself before re-dispatching.
|
|
444
|
+
*/
|
|
445
|
+
park: NodeJS.Timeout | null;
|
|
446
|
+
/** Consecutive parks on this queue; reset when a turn ends without one (or
|
|
447
|
+
* an external message arrives). Capped by {@link MAX_PARKS}. */
|
|
448
|
+
parkCount: number;
|
|
449
|
+
/** Total park time accumulated across consecutive parks (ms). Capped by
|
|
450
|
+
* {@link MAX_WAIT_MS}. */
|
|
451
|
+
parkAccumMs: number;
|
|
406
452
|
}
|
|
407
453
|
|
|
408
454
|
interface CloudRunBudgetCaps {
|
|
@@ -774,6 +820,9 @@ export class Dispatcher {
|
|
|
774
820
|
cancelGen: 0,
|
|
775
821
|
serialBuffer: [],
|
|
776
822
|
serialWorkerActive: false,
|
|
823
|
+
park: null,
|
|
824
|
+
parkCount: 0,
|
|
825
|
+
parkAccumMs: 0,
|
|
777
826
|
};
|
|
778
827
|
this.queues.set(key, q);
|
|
779
828
|
}
|
|
@@ -981,6 +1030,7 @@ export class Dispatcher {
|
|
|
981
1030
|
mergedFromTurnIds: string[] = [],
|
|
982
1031
|
): Promise<void> {
|
|
983
1032
|
const q = this.getQueue(queueKey);
|
|
1033
|
+
this.supersedePendingPark(q);
|
|
984
1034
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
985
1035
|
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
986
1036
|
// they resume and drop out, so only the newest message reaches runTurn.
|
|
@@ -1069,6 +1119,7 @@ export class Dispatcher {
|
|
|
1069
1119
|
mergedFromTurnIds: string[] = [],
|
|
1070
1120
|
): Promise<void> {
|
|
1071
1121
|
const q = this.getQueue(queueKey);
|
|
1122
|
+
this.supersedePendingPark(q);
|
|
1072
1123
|
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
1073
1124
|
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
1074
1125
|
const dropped = q.serialBuffer.shift()!;
|
|
@@ -1282,6 +1333,21 @@ export class Dispatcher {
|
|
|
1282
1333
|
};
|
|
1283
1334
|
q.current = slot;
|
|
1284
1335
|
|
|
1336
|
+
// Agent-driven `botcord wait` is offered only in non-owner BotCord group
|
|
1337
|
+
// rooms (kind === "group"). When eligible, scope the park marker per queue
|
|
1338
|
+
// (concurrent group-room turns share one agent workspace) and expose its
|
|
1339
|
+
// path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
|
|
1340
|
+
const parkEligible =
|
|
1341
|
+
isBotCordChannel(channel) &&
|
|
1342
|
+
!isOwnerChatRoom(msg) &&
|
|
1343
|
+
msg.conversation.kind === "group";
|
|
1344
|
+
const waitMarkerFile = parkEligible
|
|
1345
|
+
? resolveWaitMarkerPath(route.cwd, queueKey)
|
|
1346
|
+
: undefined;
|
|
1347
|
+
// Drop any stale marker so that whatever `botcord wait` writes during this
|
|
1348
|
+
// turn is unambiguously from this turn (read back in `finally`).
|
|
1349
|
+
if (waitMarkerFile) clearWaitMarker(waitMarkerFile);
|
|
1350
|
+
|
|
1285
1351
|
// Dispatched record — marks "this turn entered runtime".
|
|
1286
1352
|
{
|
|
1287
1353
|
const composedField = truncateTextField(text);
|
|
@@ -1673,6 +1739,7 @@ export class Dispatcher {
|
|
|
1673
1739
|
cwd: route.cwd,
|
|
1674
1740
|
accountId: msg.accountId,
|
|
1675
1741
|
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
1742
|
+
...(waitMarkerFile ? { waitMarkerFile } : {}),
|
|
1676
1743
|
extraArgs: route.extraArgs,
|
|
1677
1744
|
signal: controller.signal,
|
|
1678
1745
|
trustLevel,
|
|
@@ -2083,12 +2150,27 @@ export class Dispatcher {
|
|
|
2083
2150
|
return;
|
|
2084
2151
|
}
|
|
2085
2152
|
|
|
2153
|
+
const attachments =
|
|
2154
|
+
(isOwnerChat && isBotCordChannel(channel)
|
|
2155
|
+
? collectOwnerChatReplyAttachments(replyText, route.cwd)
|
|
2156
|
+
: undefined) ?? [];
|
|
2157
|
+
if (attachments.length > 0) {
|
|
2158
|
+
this.log.info("dispatcher: attaching owner-chat reply artifacts", {
|
|
2159
|
+
agentId: msg.accountId,
|
|
2160
|
+
roomId: msg.conversation.id,
|
|
2161
|
+
topicId: msg.conversation.threadId ?? null,
|
|
2162
|
+
turnId,
|
|
2163
|
+
count: attachments.length,
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2086
2167
|
const sendResult = await this.sendReply(channel, {
|
|
2087
2168
|
channel: msg.channel,
|
|
2088
2169
|
accountId: msg.accountId,
|
|
2089
2170
|
conversationId: msg.conversation.id,
|
|
2090
2171
|
threadId: msg.conversation.threadId ?? null,
|
|
2091
2172
|
text: replyText,
|
|
2173
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
2092
2174
|
replyTo: this.providerReplyTo(msg),
|
|
2093
2175
|
traceId: msg.trace?.id ?? null,
|
|
2094
2176
|
}, turnId);
|
|
@@ -2117,10 +2199,126 @@ export class Dispatcher {
|
|
|
2117
2199
|
// newer arrival should find `q.current === slot`, call `abort()`, and
|
|
2118
2200
|
// let our abort-checks above drop this turn silently.
|
|
2119
2201
|
if (q.current === slot) q.current = null;
|
|
2202
|
+
// Agent-driven defer: a group-room turn may have run `botcord wait` to
|
|
2203
|
+
// park its decision. Honor it now (timer lives here, not in the runtime).
|
|
2204
|
+
this.maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile);
|
|
2120
2205
|
resolveDone();
|
|
2121
2206
|
}
|
|
2122
2207
|
}
|
|
2123
2208
|
|
|
2209
|
+
/**
|
|
2210
|
+
* Clear a pending re-wake timer because an external message just arrived on
|
|
2211
|
+
* the queue (it supersedes the scheduled re-wake and restarts the dithering
|
|
2212
|
+
* budget). No-op when the timer-fire path already nulled `q.park` — so a
|
|
2213
|
+
* re-wake does NOT reset the consecutive-park counters, keeping the caps
|
|
2214
|
+
* effective across re-wakes.
|
|
2215
|
+
*/
|
|
2216
|
+
private supersedePendingPark(q: QueueState): void {
|
|
2217
|
+
if (!q.park) return;
|
|
2218
|
+
clearTimeout(q.park);
|
|
2219
|
+
q.park = null;
|
|
2220
|
+
q.parkCount = 0;
|
|
2221
|
+
q.parkAccumMs = 0;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Read the park marker a group-room turn may have written via `botcord wait`
|
|
2226
|
+
* and, if present and within the per-queue caps ({@link MAX_PARKS} /
|
|
2227
|
+
* {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
|
|
2228
|
+
* message after the (clamped) wait. A turn that ends without a marker — or in
|
|
2229
|
+
* a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
|
|
2230
|
+
* resets the consecutive-park counters. New messages arriving during the wait
|
|
2231
|
+
* cancel it via {@link supersedePendingPark} (the agent then re-decides with
|
|
2232
|
+
* fresh context). `waitMarkerFile` is the per-queue marker path resolved at
|
|
2233
|
+
* dispatch (undefined when the room is not park-eligible).
|
|
2234
|
+
*/
|
|
2235
|
+
private maybeSchedulePark(
|
|
2236
|
+
queueKey: string,
|
|
2237
|
+
route: GatewayRoute,
|
|
2238
|
+
msg: GatewayInboundEnvelope["message"],
|
|
2239
|
+
channel: ChannelAdapter,
|
|
2240
|
+
slot: TurnSlot,
|
|
2241
|
+
controller: AbortController,
|
|
2242
|
+
waitMarkerFile: string | undefined,
|
|
2243
|
+
): void {
|
|
2244
|
+
const q = this.queues.get(queueKey);
|
|
2245
|
+
if (!q) return;
|
|
2246
|
+
|
|
2247
|
+
if (!waitMarkerFile || slot.timedOut || controller.signal.aborted) {
|
|
2248
|
+
// Not eligible / unclean completion — never honor a marker here.
|
|
2249
|
+
if (waitMarkerFile) clearWaitMarker(waitMarkerFile);
|
|
2250
|
+
q.parkCount = 0;
|
|
2251
|
+
q.parkAccumMs = 0;
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
const marker = consumeWaitMarker(waitMarkerFile);
|
|
2256
|
+
if (!marker) {
|
|
2257
|
+
q.parkCount = 0;
|
|
2258
|
+
q.parkAccumMs = 0;
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
if (q.parkCount >= MAX_PARKS || q.parkAccumMs >= MAX_WAIT_MS) {
|
|
2263
|
+
this.log.info("dispatcher: park request ignored — cap reached", {
|
|
2264
|
+
agentId: msg.accountId,
|
|
2265
|
+
roomId: msg.conversation.id,
|
|
2266
|
+
topicId: msg.conversation.threadId ?? null,
|
|
2267
|
+
queueKey,
|
|
2268
|
+
parkCount: q.parkCount,
|
|
2269
|
+
parkAccumMs: q.parkAccumMs,
|
|
2270
|
+
});
|
|
2271
|
+
q.parkCount = 0;
|
|
2272
|
+
q.parkAccumMs = 0;
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const remainingBudget = MAX_WAIT_MS - q.parkAccumMs;
|
|
2277
|
+
const waitMs = Math.max(0, Math.min(marker.deadlineMs - Date.now(), remainingBudget));
|
|
2278
|
+
if (waitMs <= 0) {
|
|
2279
|
+
q.parkCount = 0;
|
|
2280
|
+
q.parkAccumMs = 0;
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
q.parkCount += 1;
|
|
2285
|
+
q.parkAccumMs += waitMs;
|
|
2286
|
+
this.log.info("dispatcher: parking group-room turn (botcord wait)", {
|
|
2287
|
+
agentId: msg.accountId,
|
|
2288
|
+
roomId: msg.conversation.id,
|
|
2289
|
+
topicId: msg.conversation.threadId ?? null,
|
|
2290
|
+
queueKey,
|
|
2291
|
+
waitMs,
|
|
2292
|
+
parkCount: q.parkCount,
|
|
2293
|
+
reason: marker.reason ?? null,
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
const timer = setTimeout(() => {
|
|
2297
|
+
q.park = null;
|
|
2298
|
+
this.log.info("dispatcher: park elapsed — re-waking", {
|
|
2299
|
+
agentId: msg.accountId,
|
|
2300
|
+
roomId: msg.conversation.id,
|
|
2301
|
+
topicId: msg.conversation.threadId ?? null,
|
|
2302
|
+
queueKey,
|
|
2303
|
+
});
|
|
2304
|
+
void this.runSerial(
|
|
2305
|
+
queueKey,
|
|
2306
|
+
route,
|
|
2307
|
+
this.recomposeUserTurn(msg),
|
|
2308
|
+
msg,
|
|
2309
|
+
channel,
|
|
2310
|
+
randomUUID(),
|
|
2311
|
+
).catch((err) => {
|
|
2312
|
+
this.log.warn("dispatcher: park re-wake failed", {
|
|
2313
|
+
queueKey,
|
|
2314
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2315
|
+
});
|
|
2316
|
+
});
|
|
2317
|
+
}, waitMs);
|
|
2318
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
2319
|
+
q.park = timer;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2124
2322
|
private async sendReply(
|
|
2125
2323
|
channel: ChannelAdapter,
|
|
2126
2324
|
outbound: GatewayOutboundMessage,
|
|
@@ -2239,6 +2437,100 @@ function nowIso(): string {
|
|
|
2239
2437
|
return new Date().toISOString();
|
|
2240
2438
|
}
|
|
2241
2439
|
|
|
2440
|
+
function collectOwnerChatReplyAttachments(text: string, cwd: string): GatewayOutboundMessage["attachments"] {
|
|
2441
|
+
const baseDir = safeRealpath(cwd);
|
|
2442
|
+
if (!baseDir) return undefined;
|
|
2443
|
+
|
|
2444
|
+
const out: NonNullable<GatewayOutboundMessage["attachments"]> = [];
|
|
2445
|
+
const seen = new Set<string>();
|
|
2446
|
+
REPLY_LOCAL_PATH_RE.lastIndex = 0;
|
|
2447
|
+
|
|
2448
|
+
for (const match of text.matchAll(REPLY_LOCAL_PATH_RE)) {
|
|
2449
|
+
const rawPath = match[2];
|
|
2450
|
+
if (!rawPath || looksLikeUrl(rawPath)) continue;
|
|
2451
|
+
|
|
2452
|
+
const resolved = path.isAbsolute(rawPath)
|
|
2453
|
+
? path.resolve(rawPath)
|
|
2454
|
+
: path.resolve(baseDir, rawPath);
|
|
2455
|
+
const realPath = safeRealpath(resolved);
|
|
2456
|
+
if (!realPath || seen.has(realPath) || !isPathInside(baseDir, realPath)) continue;
|
|
2457
|
+
|
|
2458
|
+
const ext = path.extname(realPath).toLowerCase();
|
|
2459
|
+
if (!AUTO_ATTACHMENT_EXTENSIONS.has(ext)) continue;
|
|
2460
|
+
|
|
2461
|
+
let size = 0;
|
|
2462
|
+
try {
|
|
2463
|
+
const stat = statSync(realPath);
|
|
2464
|
+
if (!stat.isFile()) continue;
|
|
2465
|
+
size = stat.size;
|
|
2466
|
+
} catch {
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
if (size <= 0 || size > AUTO_ATTACHMENT_MAX_BYTES) continue;
|
|
2470
|
+
|
|
2471
|
+
const contentType = contentTypeForExtension(ext);
|
|
2472
|
+
out.push({
|
|
2473
|
+
filePath: realPath,
|
|
2474
|
+
filename: path.basename(realPath),
|
|
2475
|
+
sourcePath: rawPath,
|
|
2476
|
+
...(contentType ? { contentType } : {}),
|
|
2477
|
+
...(contentType?.startsWith("image/") ? { kind: "image" as const } : { kind: "file" as const }),
|
|
2478
|
+
});
|
|
2479
|
+
seen.add(realPath);
|
|
2480
|
+
if (out.length >= AUTO_ATTACHMENT_LIMIT) break;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
return out.length > 0 ? out : undefined;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
function safeRealpath(input: string): string | null {
|
|
2487
|
+
try {
|
|
2488
|
+
return realpathSync(input);
|
|
2489
|
+
} catch {
|
|
2490
|
+
return null;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function isPathInside(baseDir: string, candidate: string): boolean {
|
|
2495
|
+
const rel = path.relative(baseDir, candidate);
|
|
2496
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function looksLikeUrl(value: string): boolean {
|
|
2500
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//");
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function contentTypeForExtension(ext: string): string | undefined {
|
|
2504
|
+
switch (ext) {
|
|
2505
|
+
case ".avif":
|
|
2506
|
+
return "image/avif";
|
|
2507
|
+
case ".bmp":
|
|
2508
|
+
return "image/bmp";
|
|
2509
|
+
case ".csv":
|
|
2510
|
+
return "text/csv";
|
|
2511
|
+
case ".gif":
|
|
2512
|
+
return "image/gif";
|
|
2513
|
+
case ".htm":
|
|
2514
|
+
case ".html":
|
|
2515
|
+
return "text/html";
|
|
2516
|
+
case ".jpeg":
|
|
2517
|
+
case ".jpg":
|
|
2518
|
+
return "image/jpeg";
|
|
2519
|
+
case ".pdf":
|
|
2520
|
+
return "application/pdf";
|
|
2521
|
+
case ".png":
|
|
2522
|
+
return "image/png";
|
|
2523
|
+
case ".svg":
|
|
2524
|
+
return "image/svg+xml";
|
|
2525
|
+
case ".webp":
|
|
2526
|
+
return "image/webp";
|
|
2527
|
+
case ".zip":
|
|
2528
|
+
return "application/zip";
|
|
2529
|
+
default:
|
|
2530
|
+
return undefined;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2242
2534
|
function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
|
|
2243
2535
|
const thread = msg.conversation.threadId ?? "";
|
|
2244
2536
|
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|
package/src/gateway/types.ts
CHANGED
|
@@ -364,6 +364,14 @@ export interface RuntimeRunOptions {
|
|
|
364
364
|
* unspecified and the bundled CLI falls back to its own default.
|
|
365
365
|
*/
|
|
366
366
|
hubUrl?: string;
|
|
367
|
+
/**
|
|
368
|
+
* Absolute path the spawned CLI should write a `botcord wait` park marker to,
|
|
369
|
+
* exposed to the subprocess as `BOTCORD_WAIT_FILE`. Scoped per queue by the
|
|
370
|
+
* dispatcher so concurrent group-room turns sharing one workspace don't
|
|
371
|
+
* clobber each other. Unset for non-deferrable rooms (owner-chat / DM /
|
|
372
|
+
* non-group), in which case `botcord wait` is a harmless no-op.
|
|
373
|
+
*/
|
|
374
|
+
waitMarkerFile?: string;
|
|
367
375
|
signal: AbortSignal;
|
|
368
376
|
extraArgs?: string[];
|
|
369
377
|
trustLevel: TrustLevel;
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
|
|
26
|
+
/** Filename stem for park markers. */
|
|
27
|
+
export const WAIT_MARKER_PREFIX = ".botcord-wait";
|
|
28
|
+
/** Legacy unscoped marker name — the CLI's fallback when `BOTCORD_WAIT_FILE`
|
|
29
|
+
* is unset. Never consumed by the dispatcher (which always scopes per queue),
|
|
30
|
+
* so an unscoped write is a harmless no-op. */
|
|
31
|
+
export const WAIT_MARKER_FILENAME = `${WAIT_MARKER_PREFIX}.json`;
|
|
32
|
+
|
|
33
|
+
/** Hard ceiling on a single park request (mirrors the CLI clamp). Also used by
|
|
34
|
+
* the dispatcher as the total accumulated-park budget across consecutive
|
|
35
|
+
* re-wakes on one queue. */
|
|
36
|
+
export const MAX_WAIT_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
export interface WaitMarker {
|
|
39
|
+
/** Absolute unix millis the agent wants to be re-woken by (already clamped). */
|
|
40
|
+
deadlineMs: number;
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Legacy unscoped path under `cwd` (CLI fallback / tests). */
|
|
45
|
+
export function waitMarkerPath(cwd: string): string {
|
|
46
|
+
return path.join(cwd, WAIT_MARKER_FILENAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per-queue marker path under the agent workspace. */
|
|
50
|
+
export function resolveWaitMarkerPath(cwd: string, queueKey: string): string {
|
|
51
|
+
const safe = queueKey.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
52
|
+
return path.join(cwd, `${WAIT_MARKER_PREFIX}.${safe}.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Best-effort delete of any pre-existing marker at `markerPath`. Called before
|
|
56
|
+
* a turn runs so that whatever `botcord wait` writes during this turn is
|
|
57
|
+
* unambiguously from this turn. */
|
|
58
|
+
export function clearWaitMarker(markerPath: string): void {
|
|
59
|
+
try {
|
|
60
|
+
fs.rmSync(markerPath, { force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
// A leftover marker only costs one wasted park-check next turn — never
|
|
63
|
+
// let cleanup failure abort the dispatch path.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read + delete the marker a turn may have written via `botcord wait`, and
|
|
69
|
+
* return the validated request (or null). Always removes the file so the next
|
|
70
|
+
* turn starts clean. `now` is injectable for tests.
|
|
71
|
+
*
|
|
72
|
+
* A deadline in the past — or beyond {@link MAX_WAIT_MS} — is clamped; a
|
|
73
|
+
* non-positive remaining wait yields null (treated as "no wait").
|
|
74
|
+
*/
|
|
75
|
+
export function consumeWaitMarker(markerPath: string, now: number = Date.now()): WaitMarker | null {
|
|
76
|
+
let raw: string;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(markerPath, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return null; // no marker (ENOENT) or unreadable
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
fs.rmSync(markerPath, { force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore — the pre-turn clear will catch it next time
|
|
86
|
+
}
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
94
|
+
const obj = parsed as Record<string, unknown>;
|
|
95
|
+
const deadlineMs = typeof obj.deadlineMs === "number" ? obj.deadlineMs : NaN;
|
|
96
|
+
if (!Number.isFinite(deadlineMs)) return null;
|
|
97
|
+
const clamped = Math.min(deadlineMs, now + MAX_WAIT_MS);
|
|
98
|
+
if (clamped <= now) return null;
|
|
99
|
+
const reason = typeof obj.reason === "string" ? obj.reason : undefined;
|
|
100
|
+
return reason !== undefined ? { deadlineMs: clamped, reason } : { deadlineMs: clamped };
|
|
101
|
+
}
|
package/src/gateway-control.ts
CHANGED
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
startFeishuRegistration,
|
|
39
39
|
type FeishuDomain,
|
|
40
40
|
} from "./gateway/channels/feishu-registration.js";
|
|
41
|
+
import {
|
|
42
|
+
discoverFeishuChats,
|
|
43
|
+
type FeishuDiscoveredChat,
|
|
44
|
+
} from "./gateway/channels/feishu.js";
|
|
41
45
|
import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
|
|
42
46
|
import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
|
|
43
47
|
import { log as daemonLog } from "./log.js";
|
|
@@ -172,8 +176,17 @@ interface GatewayRecentSender {
|
|
|
172
176
|
label?: string | null;
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
interface GatewayRecentFeishuChat {
|
|
180
|
+
chatId: string;
|
|
181
|
+
senderOpenId: string;
|
|
182
|
+
kind: "direct" | "group";
|
|
183
|
+
label?: string | null;
|
|
184
|
+
lastSeenAt: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
interface GatewayRecentSendersResult {
|
|
176
|
-
senders
|
|
188
|
+
senders?: GatewayRecentSender[];
|
|
189
|
+
chats?: GatewayRecentFeishuChat[];
|
|
177
190
|
}
|
|
178
191
|
|
|
179
192
|
interface GatewaySendParams {
|
|
@@ -213,6 +226,9 @@ export interface GatewayControlContext {
|
|
|
213
226
|
startFeishuRegistration: typeof startFeishuRegistration;
|
|
214
227
|
pollFeishuRegistration: typeof pollFeishuRegistration;
|
|
215
228
|
};
|
|
229
|
+
feishuDiscoveryClient?: {
|
|
230
|
+
discoverChats: typeof discoverFeishuChats;
|
|
231
|
+
};
|
|
216
232
|
/** Override the global fetch — used by `test_gateway` for Telegram getMe. */
|
|
217
233
|
fetchImpl?: FetchLike;
|
|
218
234
|
}
|
|
@@ -228,6 +244,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
228
244
|
const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
|
|
229
245
|
const feishuLogin =
|
|
230
246
|
ctx.feishuLoginClient ?? { startFeishuRegistration, pollFeishuRegistration };
|
|
247
|
+
const feishuDiscovery = ctx.feishuDiscoveryClient ?? { discoverChats: discoverFeishuChats };
|
|
231
248
|
// W7: validate fetch availability at construction so a missing global is
|
|
232
249
|
// diagnosed at startup, not during the first control frame. Tests inject
|
|
233
250
|
// `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
|
|
@@ -898,7 +915,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
898
915
|
if (!isProvider(params.provider)) {
|
|
899
916
|
return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
|
|
900
917
|
}
|
|
901
|
-
if (params.provider !== "wechat") {
|
|
918
|
+
if (params.provider !== "wechat" && params.provider !== "feishu") {
|
|
902
919
|
return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
|
|
903
920
|
}
|
|
904
921
|
if (!params.loginId) {
|
|
@@ -913,12 +930,12 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
913
930
|
ok: false,
|
|
914
931
|
error:
|
|
915
932
|
resolved.state === "missing"
|
|
916
|
-
? { code: "login_missing", message:
|
|
917
|
-
: { code: "login_expired", message:
|
|
933
|
+
? { code: "login_missing", message: `${params.provider} login session "${params.loginId}" not found` }
|
|
934
|
+
: { code: "login_expired", message: `${params.provider} login session "${params.loginId}" expired` },
|
|
918
935
|
};
|
|
919
936
|
}
|
|
920
937
|
const session = resolved.session!;
|
|
921
|
-
if (session.provider !==
|
|
938
|
+
if (session.provider !== params.provider) {
|
|
922
939
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
923
940
|
}
|
|
924
941
|
if (session.accountId !== params.accountId) {
|
|
@@ -930,6 +947,43 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
930
947
|
},
|
|
931
948
|
};
|
|
932
949
|
}
|
|
950
|
+
if (params.provider === "feishu") {
|
|
951
|
+
if (!session.appId || !session.appSecret || !session.userOpenId) {
|
|
952
|
+
return {
|
|
953
|
+
ok: false,
|
|
954
|
+
error: {
|
|
955
|
+
code: "login_unconfirmed",
|
|
956
|
+
message: "feishu login session has no app credentials yet",
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const chats = await feishuDiscovery.discoverChats({
|
|
962
|
+
appId: session.appId,
|
|
963
|
+
appSecret: session.appSecret,
|
|
964
|
+
domain: session.domain ?? "feishu",
|
|
965
|
+
userOpenId: session.userOpenId,
|
|
966
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
967
|
+
});
|
|
968
|
+
const result: GatewayRecentSendersResult = {
|
|
969
|
+
chats: chats.map((c: FeishuDiscoveredChat) => ({
|
|
970
|
+
chatId: c.chatId,
|
|
971
|
+
senderOpenId: c.senderOpenId,
|
|
972
|
+
kind: c.kind,
|
|
973
|
+
label: c.label ?? null,
|
|
974
|
+
lastSeenAt: c.lastSeenAt,
|
|
975
|
+
})),
|
|
976
|
+
};
|
|
977
|
+
return { ok: true, result };
|
|
978
|
+
} catch (err) {
|
|
979
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
980
|
+
daemonLog.warn("gateway_recent_senders.feishu discovery failed", { error: message });
|
|
981
|
+
return {
|
|
982
|
+
ok: false,
|
|
983
|
+
error: { code: "provider_unreachable", message },
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
933
987
|
if (!session.botToken) {
|
|
934
988
|
return {
|
|
935
989
|
ok: false,
|
package/src/loop-risk.ts
CHANGED
|
@@ -91,6 +91,7 @@ export function stripBotCordPromptScaffolding(text: string): string {
|
|
|
91
91
|
if (line.startsWith("keyword, sender rule")) return false;
|
|
92
92
|
if (line.startsWith("you do not reply to the group")) return false;
|
|
93
93
|
if (line.startsWith("Before replying NO_REPLY in a non-owner group room")) return false;
|
|
94
|
+
if (line.startsWith("If you might have something useful to add")) return false;
|
|
94
95
|
if (line.startsWith("match a memory-backed monitoring rule")) return false;
|
|
95
96
|
if (line.startsWith("or owner-approved workflow. If needed")) return false;
|
|
96
97
|
if (line.startsWith("[If the conversation has naturally concluded")) return false;
|
package/src/system-context.ts
CHANGED
|
@@ -55,6 +55,9 @@ function buildOwnerChatSceneContext(): string {
|
|
|
55
55
|
"This is a private, trusted conversation. The owner has full administrative authority over you.",
|
|
56
56
|
"Messages from the owner are always trusted and do not require sanitization or safety guards.",
|
|
57
57
|
"You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
|
|
58
|
+
"The owner is reading your reply in the remote BotCord dashboard; they cannot open this machine's local filesystem paths.",
|
|
59
|
+
"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.",
|
|
60
|
+
"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.",
|
|
58
61
|
].join("\n");
|
|
59
62
|
}
|
|
60
63
|
|