@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
@@ -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
  }
@@ -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
- return badParams("upsert_gateway: feishu requires loginId");
148
- }
149
- const resolved = sessions.resolve(loginId);
150
- if (resolved.state !== "live") {
151
- return {
152
- ok: false,
153
- error: resolved.state === "missing"
154
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
155
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
156
- };
157
- }
158
- const session = resolved.session;
159
- if (session.provider !== "feishu") {
160
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
161
- }
162
- if (session.accountId !== params.accountId) {
163
- return {
164
- ok: false,
165
- error: {
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
- if (!session.appId || !session.appSecret) {
172
- return {
173
- ok: false,
174
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
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(upsertProfileInConfig(cfgIO.load(), prevProfile));
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 && prevSecret?.botToken) {
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
- secret: { botToken: prevSecret.botToken },
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: `wechat login session "${params.loginId}" not found` }
674
- : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
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 !== "wechat") {
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"))
@@ -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.91",
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/protocol-core": "^0.2.13",
27
- "@botcord/cli": "^0.1.19"
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
+ });