@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/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.
|
|
@@ -271,6 +288,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
271
288
|
}
|
|
272
289
|
|
|
273
290
|
const cfg = cfgIO.load();
|
|
291
|
+
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
292
|
+
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
293
|
+
const hadExistingProfile = prevProfile !== undefined;
|
|
274
294
|
|
|
275
295
|
// accountId must belong to a daemon-bound agent. An empty agent set
|
|
276
296
|
// (no agents provisioned yet) is itself a hard reject — otherwise we
|
|
@@ -349,43 +369,66 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
349
369
|
} else if (params.type === "feishu") {
|
|
350
370
|
const loginId = params.loginId;
|
|
351
371
|
if (!loginId) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
372
|
+
if (
|
|
373
|
+
!prevProfile ||
|
|
374
|
+
prevProfile.type !== "feishu" ||
|
|
375
|
+
prevProfile.accountId !== params.accountId ||
|
|
376
|
+
!prevProfile.appId
|
|
377
|
+
) {
|
|
378
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
379
|
+
}
|
|
380
|
+
const existing = loadGatewaySecret<{ appSecret?: string }>(params.id);
|
|
381
|
+
if (!existing?.appSecret) {
|
|
382
|
+
return badParams("upsert_gateway: feishu requires loginId");
|
|
383
|
+
}
|
|
384
|
+
if (
|
|
385
|
+
params.settings?.domain !== undefined &&
|
|
386
|
+
params.settings.domain !== (prevProfile.domain ?? "feishu")
|
|
387
|
+
) {
|
|
388
|
+
return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
|
|
389
|
+
}
|
|
390
|
+
secretPayload = { appSecret: existing.appSecret };
|
|
391
|
+
tokenPreviewSource = existing.appSecret;
|
|
392
|
+
feishuAppId = prevProfile.appId;
|
|
393
|
+
feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
|
|
394
|
+
feishuUserOpenId = prevProfile.userOpenId;
|
|
395
|
+
} else {
|
|
396
|
+
const resolved = sessions.resolve(loginId);
|
|
397
|
+
if (resolved.state !== "live") {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
error:
|
|
401
|
+
resolved.state === "missing"
|
|
402
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
403
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const session = resolved.session!;
|
|
407
|
+
if (session.provider !== "feishu") {
|
|
408
|
+
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
409
|
+
}
|
|
410
|
+
if (session.accountId !== params.accountId) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: {
|
|
414
|
+
code: "login_account_mismatch",
|
|
415
|
+
message: "feishu login session accountId does not match upsert request",
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (!session.appId || !session.appSecret) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
secretPayload = { appSecret: session.appSecret };
|
|
426
|
+
tokenPreviewSource = session.appSecret;
|
|
427
|
+
feishuAppId = session.appId;
|
|
428
|
+
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
429
|
+
feishuUserOpenId = session.userOpenId;
|
|
430
|
+
sessions.update(loginId, { gatewayId: params.id });
|
|
382
431
|
}
|
|
383
|
-
secretPayload = { appSecret: session.appSecret };
|
|
384
|
-
tokenPreviewSource = session.appSecret;
|
|
385
|
-
feishuAppId = session.appId;
|
|
386
|
-
feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
|
|
387
|
-
feishuUserOpenId = session.userOpenId;
|
|
388
|
-
sessions.update(loginId, { gatewayId: params.id });
|
|
389
432
|
} else {
|
|
390
433
|
return badParams(`upsert_gateway: unknown provider "${(params as { type: string }).type}"`);
|
|
391
434
|
}
|
|
@@ -393,12 +436,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
393
436
|
// W3/W6: remember whether a profile already exists for this id BEFORE we
|
|
394
437
|
// write the secret/config. For UPDATE path, capture previous profile +
|
|
395
438
|
// previous secret so addChannel failure can restore prior state.
|
|
396
|
-
const existingProfiles = cfg.thirdPartyGateways ?? [];
|
|
397
|
-
const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
|
|
398
|
-
const prevProfile = existingProfiles.find((g) => g.id === params.id);
|
|
399
439
|
// W6: load the previous secret for UPDATE rollback BEFORE overwriting.
|
|
400
440
|
const prevSecret = hadExistingProfile
|
|
401
|
-
? loadGatewaySecret<{ botToken?: string }>(params.id)
|
|
441
|
+
? loadGatewaySecret<{ botToken?: string; appSecret?: string }>(params.id)
|
|
402
442
|
: null;
|
|
403
443
|
|
|
404
444
|
// Persist secret first (so a config write that succeeds is never
|
|
@@ -458,20 +498,27 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
458
498
|
}
|
|
459
499
|
try {
|
|
460
500
|
if (prevProfile) {
|
|
461
|
-
cfgIO.save(
|
|
501
|
+
cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
|
|
462
502
|
}
|
|
463
503
|
} catch {
|
|
464
504
|
// best-effort
|
|
465
505
|
}
|
|
466
506
|
try {
|
|
467
|
-
if (
|
|
507
|
+
if (
|
|
508
|
+
prevProfile &&
|
|
509
|
+
((prevProfile.type === "telegram" && prevSecret?.botToken) ||
|
|
510
|
+
(prevProfile.type === "feishu" && prevSecret?.appSecret))
|
|
511
|
+
) {
|
|
468
512
|
await ctx.gateway.addChannel(
|
|
469
513
|
buildChannelConfig(
|
|
470
514
|
{
|
|
471
515
|
...params,
|
|
472
516
|
type: prevProfile.type as typeof params.type,
|
|
517
|
+
accountId: prevProfile.accountId,
|
|
473
518
|
enabled: prevProfile.enabled !== false,
|
|
474
|
-
|
|
519
|
+
...(prevProfile.type === "telegram"
|
|
520
|
+
? { secret: { botToken: prevSecret.botToken } }
|
|
521
|
+
: {}),
|
|
475
522
|
settings: {
|
|
476
523
|
baseUrl: prevProfile.baseUrl,
|
|
477
524
|
allowedSenderIds: prevProfile.allowedSenderIds,
|
|
@@ -868,7 +915,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
868
915
|
if (!isProvider(params.provider)) {
|
|
869
916
|
return badParams(`gateway_recent_senders: unknown provider "${String(params.provider)}"`);
|
|
870
917
|
}
|
|
871
|
-
if (params.provider !== "wechat") {
|
|
918
|
+
if (params.provider !== "wechat" && params.provider !== "feishu") {
|
|
872
919
|
return badParams(`gateway_recent_senders: provider "${params.provider}" not supported`);
|
|
873
920
|
}
|
|
874
921
|
if (!params.loginId) {
|
|
@@ -883,12 +930,12 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
883
930
|
ok: false,
|
|
884
931
|
error:
|
|
885
932
|
resolved.state === "missing"
|
|
886
|
-
? { code: "login_missing", message:
|
|
887
|
-
: { 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` },
|
|
888
935
|
};
|
|
889
936
|
}
|
|
890
937
|
const session = resolved.session!;
|
|
891
|
-
if (session.provider !==
|
|
938
|
+
if (session.provider !== params.provider) {
|
|
892
939
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
893
940
|
}
|
|
894
941
|
if (session.accountId !== params.accountId) {
|
|
@@ -900,6 +947,43 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
900
947
|
},
|
|
901
948
|
};
|
|
902
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
|
+
}
|
|
903
987
|
if (!session.botToken) {
|
|
904
988
|
return {
|
|
905
989
|
ok: false,
|
|
@@ -1074,6 +1158,9 @@ function validateOutboundConversation(
|
|
|
1074
1158
|
},
|
|
1075
1159
|
};
|
|
1076
1160
|
}
|
|
1161
|
+
if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1077
1164
|
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
1078
1165
|
if (!allowed.has(chatId)) {
|
|
1079
1166
|
return {
|
|
@@ -1161,6 +1248,21 @@ function upsertProfileInConfig(
|
|
|
1161
1248
|
return { ...cfg, thirdPartyGateways: list };
|
|
1162
1249
|
}
|
|
1163
1250
|
|
|
1251
|
+
function replaceProfileInConfig(
|
|
1252
|
+
cfg: DaemonConfig,
|
|
1253
|
+
profile: ThirdPartyGatewayProfile,
|
|
1254
|
+
): DaemonConfig {
|
|
1255
|
+
const list = (cfg.thirdPartyGateways ?? []).slice();
|
|
1256
|
+
const idx = list.findIndex((g) => g.id === profile.id);
|
|
1257
|
+
const compact = compactProfile(profile);
|
|
1258
|
+
if (idx >= 0) {
|
|
1259
|
+
list[idx] = compact;
|
|
1260
|
+
} else {
|
|
1261
|
+
list.push(compact);
|
|
1262
|
+
}
|
|
1263
|
+
return { ...cfg, thirdPartyGateways: list };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1164
1266
|
function compactProfile(p: ThirdPartyGatewayProfile): ThirdPartyGatewayProfile {
|
|
1165
1267
|
const out: ThirdPartyGatewayProfile = {
|
|
1166
1268
|
id: p.id,
|
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
|
|
package/src/turn-text.ts
CHANGED
|
@@ -43,6 +43,11 @@ const GROUP_HINT =
|
|
|
43
43
|
"match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, " +
|
|
44
44
|
"or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update " +
|
|
45
45
|
"working memory.\n\n" +
|
|
46
|
+
"If you might have something useful to add but another participant (human or agent) may answer " +
|
|
47
|
+
"first, you may run `botcord wait <seconds>` (1-30) via Bash instead of replying now. You will " +
|
|
48
|
+
"be re-woken after the wait — or sooner if a new message arrives — so you can re-decide with " +
|
|
49
|
+
"the newer context (for example, stay silent if someone already covered it). Prefer this over " +
|
|
50
|
+
"racing to answer an unaddressed question.\n\n" +
|
|
46
51
|
'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
|
|
47
52
|
const DIRECT_HINT =
|
|
48
53
|
'[If the conversation has naturally concluded or no response is needed, ' +
|