@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.
Files changed (44) hide show
  1. package/dist/gateway/channels/botcord.d.ts +9 -1
  2. package/dist/gateway/channels/botcord.js +55 -2
  3. package/dist/gateway/channels/feishu.d.ts +56 -0
  4. package/dist/gateway/channels/feishu.js +76 -0
  5. package/dist/gateway/cli-resolver.d.ts +1 -0
  6. package/dist/gateway/cli-resolver.js +2 -0
  7. package/dist/gateway/dispatcher.d.ts +20 -0
  8. package/dist/gateway/dispatcher.js +252 -0
  9. package/dist/gateway/runtimes/codex.js +1 -0
  10. package/dist/gateway/runtimes/deepseek-tui.js +1 -0
  11. package/dist/gateway/runtimes/hermes-agent.js +1 -0
  12. package/dist/gateway/runtimes/kimi.js +1 -0
  13. package/dist/gateway/runtimes/ndjson-stream.js +1 -0
  14. package/dist/gateway/types.d.ts +8 -0
  15. package/dist/gateway/wait-marker.d.ts +32 -0
  16. package/dist/gateway/wait-marker.js +96 -0
  17. package/dist/gateway-control.d.ts +4 -0
  18. package/dist/gateway-control.js +124 -44
  19. package/dist/loop-risk.js +2 -0
  20. package/dist/system-context.js +3 -0
  21. package/dist/turn-text.js +5 -0
  22. package/package.json +3 -3
  23. package/src/__tests__/feishu-channel.test.ts +180 -0
  24. package/src/__tests__/gateway-control.test.ts +493 -0
  25. package/src/__tests__/system-context.test.ts +4 -0
  26. package/src/gateway/__tests__/botcord-channel.test.ts +50 -0
  27. package/src/gateway/__tests__/dispatcher-park.test.ts +207 -0
  28. package/src/gateway/__tests__/dispatcher.test.ts +48 -1
  29. package/src/gateway/__tests__/wait-marker.test.ts +90 -0
  30. package/src/gateway/channels/botcord.ts +79 -5
  31. package/src/gateway/channels/feishu.ts +122 -0
  32. package/src/gateway/cli-resolver.ts +2 -0
  33. package/src/gateway/dispatcher.ts +292 -0
  34. package/src/gateway/runtimes/codex.ts +1 -0
  35. package/src/gateway/runtimes/deepseek-tui.ts +1 -0
  36. package/src/gateway/runtimes/hermes-agent.ts +1 -0
  37. package/src/gateway/runtimes/kimi.ts +1 -0
  38. package/src/gateway/runtimes/ndjson-stream.ts +1 -0
  39. package/src/gateway/types.ts +8 -0
  40. package/src/gateway/wait-marker.ts +101 -0
  41. package/src/gateway-control.ts +150 -48
  42. package/src/loop-risk.ts +1 -0
  43. package/src/system-context.ts +3 -0
  44. package/src/turn-text.ts +5 -0
@@ -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: GatewayRecentSender[];
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
- return badParams("upsert_gateway: feishu requires loginId");
353
- }
354
- const resolved = sessions.resolve(loginId);
355
- if (resolved.state !== "live") {
356
- return {
357
- ok: false,
358
- error:
359
- resolved.state === "missing"
360
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
361
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
362
- };
363
- }
364
- const session = resolved.session!;
365
- if (session.provider !== "feishu") {
366
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
367
- }
368
- if (session.accountId !== params.accountId) {
369
- return {
370
- ok: false,
371
- error: {
372
- code: "login_account_mismatch",
373
- message: "feishu login session accountId does not match upsert request",
374
- },
375
- };
376
- }
377
- if (!session.appId || !session.appSecret) {
378
- return {
379
- ok: false,
380
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
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(upsertProfileInConfig(cfgIO.load(), prevProfile));
501
+ cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
462
502
  }
463
503
  } catch {
464
504
  // best-effort
465
505
  }
466
506
  try {
467
- if (prevProfile && prevSecret?.botToken) {
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
- secret: { botToken: prevSecret.botToken },
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: `wechat login session "${params.loginId}" not found` }
887
- : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
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 !== "wechat") {
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;
@@ -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, ' +