@botcord/daemon 0.2.58 → 0.2.60

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 (53) hide show
  1. package/dist/config.d.ts +4 -1
  2. package/dist/config.js +2 -2
  3. package/dist/cross-room.js +3 -1
  4. package/dist/daemon-config-map.js +6 -0
  5. package/dist/daemon.js +21 -1
  6. package/dist/diagnostics.d.ts +1 -0
  7. package/dist/diagnostics.js +35 -6
  8. package/dist/gateway/channels/feishu-registration.d.ts +35 -0
  9. package/dist/gateway/channels/feishu-registration.js +101 -0
  10. package/dist/gateway/channels/feishu.d.ts +16 -0
  11. package/dist/gateway/channels/feishu.js +459 -0
  12. package/dist/gateway/channels/index.d.ts +2 -0
  13. package/dist/gateway/channels/index.js +2 -0
  14. package/dist/gateway/channels/login-session.d.ts +9 -1
  15. package/dist/gateway/channels/login-session.js +1 -1
  16. package/dist/gateway/channels/wechat.js +26 -2
  17. package/dist/gateway/dispatcher.d.ts +3 -0
  18. package/dist/gateway/dispatcher.js +190 -30
  19. package/dist/gateway/policy-resolver.d.ts +10 -6
  20. package/dist/gateway/types.d.ts +1 -1
  21. package/dist/gateway-control.d.ts +8 -1
  22. package/dist/gateway-control.js +171 -18
  23. package/dist/index.js +9 -3
  24. package/dist/log.d.ts +9 -0
  25. package/dist/log.js +89 -1
  26. package/dist/provision.js +7 -1
  27. package/package.json +2 -1
  28. package/src/__tests__/cross-room.test.ts +2 -0
  29. package/src/__tests__/diagnostics.test.ts +37 -1
  30. package/src/__tests__/gateway-control.test.ts +84 -0
  31. package/src/__tests__/log.test.ts +28 -1
  32. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  33. package/src/__tests__/third-party-gateway.test.ts +28 -0
  34. package/src/__tests__/wechat-channel.test.ts +47 -0
  35. package/src/config.ts +6 -3
  36. package/src/cross-room.ts +3 -1
  37. package/src/daemon-config-map.ts +3 -0
  38. package/src/daemon.ts +24 -3
  39. package/src/diagnostics.ts +36 -6
  40. package/src/gateway/__tests__/dispatcher.test.ts +62 -4
  41. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  42. package/src/gateway/channels/feishu-registration.ts +155 -0
  43. package/src/gateway/channels/feishu.ts +554 -0
  44. package/src/gateway/channels/index.ts +6 -0
  45. package/src/gateway/channels/login-session.ts +10 -2
  46. package/src/gateway/channels/wechat.ts +29 -2
  47. package/src/gateway/dispatcher.ts +216 -29
  48. package/src/gateway/policy-resolver.ts +19 -11
  49. package/src/gateway/types.ts +1 -1
  50. package/src/gateway-control.ts +188 -17
  51. package/src/index.ts +9 -3
  52. package/src/log.ts +100 -1
  53. package/src/provision.ts +13 -1
@@ -12,6 +12,7 @@ import { loadConfig, saveConfig, resolveConfiguredAgentIds, } from "./config.js"
12
12
  import { deleteGatewaySecret, loadGatewaySecret, saveGatewaySecret, } from "./gateway/channels/secret-store.js";
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
+ import { pollFeishuRegistration, startFeishuRegistration, } from "./gateway/channels/feishu-registration.js";
15
16
  import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
16
17
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
17
18
  import { log as daemonLog } from "./log.js";
@@ -24,6 +25,7 @@ export function createGatewayControl(ctx) {
24
25
  const cfgIO = ctx.configIO ?? { load: loadConfig, save: saveConfig };
25
26
  const sessions = ctx.loginSessions ?? new LoginSessionStore();
26
27
  const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
28
+ const feishuLogin = ctx.feishuLoginClient ?? { startFeishuRegistration, pollFeishuRegistration };
27
29
  // W7: validate fetch availability at construction so a missing global is
28
30
  // diagnosed at startup, not during the first control frame. Tests inject
29
31
  // `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
@@ -79,9 +81,13 @@ export function createGatewayControl(ctx) {
79
81
  };
80
82
  }
81
83
  // Provider-specific secret resolution.
82
- let botToken;
84
+ let secretPayload;
85
+ let tokenPreviewSource;
86
+ let feishuAppId;
87
+ let feishuDomain;
88
+ let feishuUserOpenId;
83
89
  if (params.type === "telegram") {
84
- botToken = params.secret?.botToken;
90
+ const botToken = params.secret?.botToken;
85
91
  if (!botToken) {
86
92
  // Allow updates that only flip enabled/whitelist — only require a
87
93
  // token when none is on disk yet.
@@ -89,7 +95,12 @@ export function createGatewayControl(ctx) {
89
95
  if (!existing?.botToken) {
90
96
  return badParams("upsert_gateway: telegram requires secret.botToken on first install");
91
97
  }
92
- botToken = existing.botToken;
98
+ secretPayload = { botToken: existing.botToken };
99
+ tokenPreviewSource = existing.botToken;
100
+ }
101
+ else {
102
+ secretPayload = { botToken };
103
+ tokenPreviewSource = botToken;
93
104
  }
94
105
  }
95
106
  else if (params.type === "wechat") {
@@ -122,10 +133,48 @@ export function createGatewayControl(ctx) {
122
133
  error: { code: "login_unconfirmed", message: "wechat login session has no bot token yet" },
123
134
  };
124
135
  }
125
- botToken = session.botToken;
136
+ secretPayload = { botToken: session.botToken };
137
+ tokenPreviewSource = session.botToken;
126
138
  // Bind the session to its eventual gateway id for forensic logging.
127
139
  sessions.update(loginId, { gatewayId: params.id });
128
140
  }
141
+ else if (params.type === "feishu") {
142
+ const loginId = params.loginId;
143
+ if (!loginId) {
144
+ return badParams("upsert_gateway: feishu requires loginId");
145
+ }
146
+ const session = sessions.get(loginId);
147
+ if (!session) {
148
+ return {
149
+ ok: false,
150
+ error: { code: "login_expired", message: `feishu login session "${loginId}" not found or expired` },
151
+ };
152
+ }
153
+ if (session.provider !== "feishu") {
154
+ return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
155
+ }
156
+ if (session.accountId !== params.accountId) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: "login_account_mismatch",
161
+ message: "feishu login session accountId does not match upsert request",
162
+ },
163
+ };
164
+ }
165
+ if (!session.appId || !session.appSecret) {
166
+ return {
167
+ ok: false,
168
+ error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
169
+ };
170
+ }
171
+ secretPayload = { appSecret: session.appSecret };
172
+ tokenPreviewSource = session.appSecret;
173
+ feishuAppId = session.appId;
174
+ feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
175
+ feishuUserOpenId = session.userOpenId;
176
+ sessions.update(loginId, { gatewayId: params.id });
177
+ }
129
178
  else {
130
179
  return badParams(`upsert_gateway: unknown provider "${params.type}"`);
131
180
  }
@@ -141,7 +190,7 @@ export function createGatewayControl(ctx) {
141
190
  : null;
142
191
  // Persist secret first (so a config write that succeeds is never
143
192
  // followed by a missing-secret crash). Atomic rename inside saveSecret.
144
- const secretFile = saveGatewaySecret(params.id, { botToken });
193
+ const secretFile = saveGatewaySecret(params.id, secretPayload);
145
194
  // Update or insert the third-party gateway profile in config.
146
195
  const enabled = params.enabled !== false;
147
196
  const next = upsertProfileInConfig(cfg, {
@@ -154,6 +203,9 @@ export function createGatewayControl(ctx) {
154
203
  allowedSenderIds: params.settings?.allowedSenderIds,
155
204
  allowedChatIds: params.settings?.allowedChatIds,
156
205
  splitAt: params.settings?.splitAt,
206
+ appId: feishuAppId,
207
+ domain: feishuDomain,
208
+ userOpenId: feishuUserOpenId,
157
209
  });
158
210
  cfgIO.save(next);
159
211
  // Hot-plug. removeChannel is a no-op when the id isn't registered, so
@@ -166,7 +218,10 @@ export function createGatewayControl(ctx) {
166
218
  // best-effort
167
219
  }
168
220
  try {
169
- await ctx.gateway.addChannel(buildChannelConfig(params, secretFile));
221
+ await ctx.gateway.addChannel(buildChannelConfig(params, secretFile, {
222
+ ...(feishuAppId ? { appId: feishuAppId } : {}),
223
+ ...(feishuDomain ? { domain: feishuDomain } : {}),
224
+ }));
170
225
  }
171
226
  catch (addErr) {
172
227
  const message = addErr instanceof Error ? addErr.message : String(addErr);
@@ -210,8 +265,12 @@ export function createGatewayControl(ctx) {
210
265
  allowedSenderIds: prevProfile.allowedSenderIds,
211
266
  allowedChatIds: prevProfile.allowedChatIds,
212
267
  splitAt: prevProfile.splitAt,
268
+ domain: prevProfile.domain,
213
269
  },
214
- }, secretFile));
270
+ }, secretFile, {
271
+ ...(prevProfile.appId ? { appId: prevProfile.appId } : {}),
272
+ ...(prevProfile.domain ? { domain: prevProfile.domain } : {}),
273
+ }));
215
274
  }
216
275
  }
217
276
  catch {
@@ -246,7 +305,10 @@ export function createGatewayControl(ctx) {
246
305
  type: params.type,
247
306
  accountId: params.accountId,
248
307
  enabled,
249
- tokenPreview: maskTokenPreview(botToken),
308
+ tokenPreview: maskTokenPreview(tokenPreviewSource),
309
+ ...(feishuAppId ? { appId: feishuAppId } : {}),
310
+ ...(feishuDomain ? { domain: feishuDomain } : {}),
311
+ ...(feishuUserOpenId ? { userOpenId: feishuUserOpenId } : {}),
250
312
  ...(liveStatus ? { status: pickStatus(liveStatus) } : {}),
251
313
  };
252
314
  daemonLog.info("upsert_gateway applied", {
@@ -352,9 +414,8 @@ export function createGatewayControl(ctx) {
352
414
  return { ok: true, result };
353
415
  }
354
416
  }
355
- // WeChat: iLink has no no-side-effect probe today. Fall back to the
356
- // adapter's last poll snapshot. `authorized === true` means the secret
357
- // is loaded and at least one poll succeeded.
417
+ // WeChat/Feishu: fall back to the adapter snapshot. `authorized === true`
418
+ // means the secret is loaded and the provider client started.
358
419
  const snap = ctx.gateway.snapshot().channels[profile.id];
359
420
  const result = snap
360
421
  ? {
@@ -367,7 +428,7 @@ export function createGatewayControl(ctx) {
367
428
  },
368
429
  ...(snap.lastError ? { error: snap.lastError } : {}),
369
430
  }
370
- : { id: profile.id, ok: false, error: "wechat channel not running" };
431
+ : { id: profile.id, ok: false, error: `${profile.type} channel not running` };
371
432
  return { ok: true, result };
372
433
  }
373
434
  // --- gateway_login_start ------------------------------------------------
@@ -378,9 +439,7 @@ export function createGatewayControl(ctx) {
378
439
  if (!params.accountId || typeof params.accountId !== "string") {
379
440
  return badParams("gateway_login_start: accountId is required");
380
441
  }
381
- if (params.provider !== "wechat") {
382
- // Telegram has no qrcode flow; surface a clear error so the dashboard
383
- // can fall through to the token form.
442
+ if (params.provider !== "wechat" && params.provider !== "feishu") {
384
443
  return badParams(`gateway_login_start: provider "${params.provider}" does not require login`);
385
444
  }
386
445
  // W1: SSRF guard — `baseUrl` flows directly into an authenticated fetch.
@@ -392,6 +451,38 @@ export function createGatewayControl(ctx) {
392
451
  return badParams(urlErr.message);
393
452
  throw urlErr;
394
453
  }
454
+ if (params.provider === "feishu") {
455
+ const domain = params.domain === "lark" ? "lark" : "feishu";
456
+ try {
457
+ const r = await feishuLogin.startFeishuRegistration({ domain });
458
+ const loginId = mintLoginId("feishu");
459
+ const session = sessions.create({
460
+ loginId,
461
+ accountId: params.accountId,
462
+ ...(params.gatewayId ? { gatewayId: params.gatewayId } : {}),
463
+ provider: "feishu",
464
+ qrcode: r.deviceCode,
465
+ qrcodeUrl: r.verificationUriComplete,
466
+ domain: r.domain,
467
+ });
468
+ const result = {
469
+ loginId,
470
+ qrcode: r.deviceCode,
471
+ qrcodeUrl: r.verificationUriComplete,
472
+ expiresAt: session.expiresAt,
473
+ };
474
+ daemonLog.info("gateway_login_start", { provider: "feishu", loginId, accountId: params.accountId });
475
+ return { ok: true, result };
476
+ }
477
+ catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ daemonLog.warn("gateway_login_start.feishu failed", { error: message });
480
+ return {
481
+ ok: false,
482
+ error: { code: "provider_unreachable", message },
483
+ };
484
+ }
485
+ }
395
486
  const baseUrl = params.baseUrl ?? DEFAULT_WECHAT_BASE_URL;
396
487
  let qrcode;
397
488
  let qrcodeUrl;
@@ -466,8 +557,55 @@ export function createGatewayControl(ctx) {
466
557
  };
467
558
  return { ok: true, result };
468
559
  }
560
+ if (params.provider === "feishu") {
561
+ if (!session.qrcode) {
562
+ return {
563
+ ok: false,
564
+ error: { code: "no_qrcode", message: "login session has no device code to poll" },
565
+ };
566
+ }
567
+ try {
568
+ const probe = await feishuLogin.pollFeishuRegistration(session.qrcode, {
569
+ domain: session.domain ?? "feishu",
570
+ });
571
+ if (probe.status === "confirmed" && probe.appId && probe.appSecret) {
572
+ const tokenPreview = maskTokenPreview(probe.appSecret);
573
+ sessions.update(params.loginId, {
574
+ appId: probe.appId,
575
+ appSecret: probe.appSecret,
576
+ domain: probe.domain,
577
+ userOpenId: probe.userOpenId,
578
+ tokenPreview,
579
+ });
580
+ const result = {
581
+ status: "confirmed",
582
+ appId: probe.appId,
583
+ domain: probe.domain,
584
+ userOpenId: probe.userOpenId,
585
+ tokenPreview,
586
+ };
587
+ return { ok: true, result };
588
+ }
589
+ const status = probe.status === "denied"
590
+ ? "failed"
591
+ : probe.status === "expired"
592
+ ? "expired"
593
+ : probe.status === "failed"
594
+ ? "failed"
595
+ : "pending";
596
+ const result = { status, domain: probe.domain };
597
+ return { ok: true, result };
598
+ }
599
+ catch (err) {
600
+ const message = err instanceof Error ? err.message : String(err);
601
+ daemonLog.warn("gateway_login_status.feishu failed", { error: message });
602
+ return {
603
+ ok: false,
604
+ error: { code: "provider_unreachable", message },
605
+ };
606
+ }
607
+ }
469
608
  if (params.provider !== "wechat") {
470
- // Future provider hook — today only WeChat poll path exists.
471
609
  return badParams(`gateway_login_status: provider "${params.provider}" not supported`);
472
610
  }
473
611
  if (!session.qrcode) {
@@ -612,7 +750,7 @@ function badParams(message) {
612
750
  return { ok: false, error: { code: "bad_params", message } };
613
751
  }
614
752
  function isProvider(p) {
615
- return p === "telegram" || p === "wechat";
753
+ return p === "telegram" || p === "wechat" || p === "feishu";
616
754
  }
617
755
  function validateUpsertParams(p) {
618
756
  if (!p.id || typeof p.id !== "string")
@@ -631,6 +769,9 @@ function annotateProfile(p, status) {
631
769
  ...(p.label !== undefined ? { label: p.label } : {}),
632
770
  enabled: p.enabled !== false,
633
771
  ...(p.baseUrl !== undefined ? { baseUrl: p.baseUrl } : {}),
772
+ ...(p.appId !== undefined ? { appId: p.appId } : {}),
773
+ ...(p.domain !== undefined ? { domain: p.domain } : {}),
774
+ ...(p.userOpenId !== undefined ? { userOpenId: p.userOpenId } : {}),
634
775
  ...(p.allowedSenderIds !== undefined ? { allowedSenderIds: p.allowedSenderIds } : {}),
635
776
  ...(p.allowedChatIds !== undefined ? { allowedChatIds: p.allowedChatIds } : {}),
636
777
  ...(p.splitAt !== undefined ? { splitAt: p.splitAt } : {}),
@@ -674,6 +815,12 @@ function compactProfile(p) {
674
815
  out.enabled = p.enabled;
675
816
  if (p.baseUrl !== undefined)
676
817
  out.baseUrl = p.baseUrl;
818
+ if (p.appId !== undefined)
819
+ out.appId = p.appId;
820
+ if (p.domain !== undefined)
821
+ out.domain = p.domain;
822
+ if (p.userOpenId !== undefined)
823
+ out.userOpenId = p.userOpenId;
677
824
  if (p.allowedSenderIds !== undefined)
678
825
  out.allowedSenderIds = p.allowedSenderIds;
679
826
  if (p.allowedChatIds !== undefined)
@@ -686,7 +833,7 @@ function compactProfile(p) {
686
833
  out.stateFile = p.stateFile;
687
834
  return out;
688
835
  }
689
- function buildChannelConfig(params, secretFile) {
836
+ function buildChannelConfig(params, secretFile, extra = {}) {
690
837
  const ch = {
691
838
  id: params.id,
692
839
  type: params.type,
@@ -698,6 +845,12 @@ function buildChannelConfig(params, secretFile) {
698
845
  const s = params.settings ?? {};
699
846
  if (s.baseUrl !== undefined)
700
847
  ch.baseUrl = s.baseUrl;
848
+ if (params.type === "feishu") {
849
+ if (extra.appId)
850
+ ch.appId = extra.appId;
851
+ if (extra.domain)
852
+ ch.domain = extra.domain;
853
+ }
701
854
  if (s.allowedSenderIds !== undefined)
702
855
  ch.allowedSenderIds = s.allowedSenderIds;
703
856
  if (s.allowedChatIds !== undefined)
package/dist/index.js CHANGED
@@ -82,9 +82,12 @@ Commands:
82
82
  route list
83
83
  route remove --room <rm_xxx>|--prefix <rm_xxx>
84
84
  config Print resolved config
85
- doctor [--json] [--bundle] Scan local runtimes (${ADAPTER_LIST});
85
+ doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
86
86
  --bundle also writes a zip under
87
- ~/.botcord/diagnostics/
87
+ ~/.botcord/diagnostics/. Bundles
88
+ daemon.log plus the latest 5 rotated
89
+ logs by default; --full-log bundles
90
+ all retained rotated logs.
88
91
  memory get [--agent <ag_xxx>] [--json] Show current working memory
89
92
  memory set [--agent <ag_xxx>] --goal <text>
90
93
  Pin/update the agent's work goal
@@ -109,6 +112,7 @@ const BOOLEAN_FLAGS = new Set([
109
112
  "follow",
110
113
  "json",
111
114
  "bundle",
115
+ "full-log",
112
116
  "help",
113
117
  "h",
114
118
  "mentioned",
@@ -1216,7 +1220,9 @@ const fsFileReader = {
1216
1220
  };
1217
1221
  async function cmdDoctor(args) {
1218
1222
  if (args.flags.bundle === true) {
1219
- const bundle = await createDiagnosticBundle();
1223
+ const bundle = await createDiagnosticBundle({
1224
+ includeAllLogs: args.flags["full-log"] === true,
1225
+ });
1220
1226
  if (args.flags.json === true) {
1221
1227
  console.log(JSON.stringify({ bundle }, null, 2));
1222
1228
  return;
package/dist/log.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  type Level = "info" | "warn" | "error" | "debug";
2
+ export interface LogFileEntry {
3
+ path: string;
4
+ name: string;
5
+ sizeBytes: number;
6
+ mtimeMs: number;
7
+ active: boolean;
8
+ }
2
9
  export declare function formatLogLine(level: Level, msg: string, fields: Record<string, unknown> | undefined, date?: Date): string;
10
+ export declare function listDaemonLogFiles(logFile?: string): LogFileEntry[];
11
+ export declare function rotateLogIfNeeded(logFile?: string, nextBytes?: number, maxBytes?: number, keep?: number): void;
3
12
  export declare const log: {
4
13
  info: (msg: string, fields?: Record<string, unknown>) => void;
5
14
  warn: (msg: string, fields?: Record<string, unknown>) => void;
package/dist/log.js CHANGED
@@ -1,8 +1,10 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  const LOG_DIR = path.join(homedir(), ".botcord", "logs");
5
5
  const LOG_FILE = path.join(LOG_DIR, "daemon.log");
6
+ const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
7
+ const LOG_ROTATE_KEEP = 20;
6
8
  let inited = false;
7
9
  function ensureDir() {
8
10
  if (inited)
@@ -39,10 +41,96 @@ export function formatLogLine(level, msg, fields, date = new Date()) {
39
41
  const suffix = `ts=${date.toISOString()}`;
40
42
  return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
41
43
  }
44
+ function rotatedName(file, date = new Date()) {
45
+ const stamp = date.toISOString().replace(/[:.]/g, "-");
46
+ return `${file}.${stamp}.${process.pid}`;
47
+ }
48
+ export function listDaemonLogFiles(logFile = LOG_FILE) {
49
+ const dir = path.dirname(logFile);
50
+ const base = path.basename(logFile);
51
+ const entries = [];
52
+ try {
53
+ const st = statSync(logFile);
54
+ if (st.isFile()) {
55
+ entries.push({
56
+ path: logFile,
57
+ name: base,
58
+ sizeBytes: st.size,
59
+ mtimeMs: st.mtimeMs,
60
+ active: true,
61
+ });
62
+ }
63
+ }
64
+ catch {
65
+ // no active log
66
+ }
67
+ let names = [];
68
+ try {
69
+ names = readdirSync(dir);
70
+ }
71
+ catch {
72
+ return entries;
73
+ }
74
+ for (const name of names) {
75
+ if (!name.startsWith(`${base}.`))
76
+ continue;
77
+ const file = path.join(dir, name);
78
+ try {
79
+ const st = statSync(file);
80
+ if (!st.isFile())
81
+ continue;
82
+ entries.push({
83
+ path: file,
84
+ name,
85
+ sizeBytes: st.size,
86
+ mtimeMs: st.mtimeMs,
87
+ active: false,
88
+ });
89
+ }
90
+ catch {
91
+ // ignore disappearing files
92
+ }
93
+ }
94
+ return entries.sort((a, b) => {
95
+ if (a.active !== b.active)
96
+ return a.active ? -1 : 1;
97
+ return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
98
+ });
99
+ }
100
+ export function rotateLogIfNeeded(logFile = LOG_FILE, nextBytes = 0, maxBytes = LOG_ROTATE_MAX_BYTES, keep = LOG_ROTATE_KEEP) {
101
+ let currentSize = 0;
102
+ try {
103
+ const st = statSync(logFile);
104
+ if (!st.isFile())
105
+ return;
106
+ currentSize = st.size;
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ if (currentSize + nextBytes <= maxBytes)
112
+ return;
113
+ try {
114
+ renameSync(logFile, rotatedName(logFile));
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
120
+ for (const entry of rotated.slice(Math.max(0, keep))) {
121
+ try {
122
+ unlinkSync(entry.path);
123
+ }
124
+ catch {
125
+ // best-effort cleanup
126
+ }
127
+ }
128
+ }
42
129
  function write(level, msg, fields) {
43
130
  ensureDir();
44
131
  const line = formatLogLine(level, msg, fields);
45
132
  try {
133
+ rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
46
134
  appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
47
135
  }
48
136
  catch {
package/dist/provision.js CHANGED
@@ -164,11 +164,17 @@ export function createProvisioner(opts) {
164
164
  const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
165
165
  if (params.policy) {
166
166
  // Embedded policy payload — install directly to avoid a refetch.
167
+ const embeddedPolicy = params.policy;
167
168
  policyResolver.put(agentId, roomId ?? null, {
168
- mode: params.policy.mode,
169
+ mode: embeddedPolicy.mode === "allowed_senders"
170
+ ? "allowed_senders"
171
+ : params.policy.mode,
169
172
  keywords: Array.isArray(params.policy.keywords)
170
173
  ? params.policy.keywords.slice()
171
174
  : [],
175
+ allowedSenderIds: Array.isArray(embeddedPolicy.allowedSenderIds)
176
+ ? embeddedPolicy.allowedSenderIds.filter((id) => typeof id === "string")
177
+ : [],
172
178
  ...(typeof params.policy.muted_until === "number"
173
179
  ? { muted_until: params.policy.muted_until }
174
180
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.58",
3
+ "version": "0.2.60",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
31
  "@botcord/protocol-core": "^0.2.4",
32
+ "@larksuiteoapi/node-sdk": "^1.63.1",
32
33
  "ws": "^8.18.0"
33
34
  },
34
35
  "devDependencies": {
@@ -58,6 +58,8 @@ describe("buildCrossRoomDigest", () => {
58
58
  expect(digest).toContain("[BotCord Cross-Room Awareness]");
59
59
  // 7 rooms recorded (rm_0..rm_6), current is rm_0 → 6 others + 1 current = 7 total.
60
60
  expect(digest).toContain("You are currently active in 7 BotCord sessions");
61
+ expect(digest).toContain("latest messages from OTHER rooms, not the current room");
62
+ expect(digest).toContain("Do not treat any sender or message below as the current user");
61
63
  expect(digest).toContain("Room1 (rm_1)");
62
64
  expect(digest).toContain("Room2 (rm_2)");
63
65
  expect(digest).toContain("Room3 (rm_3)");
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
@@ -49,4 +49,40 @@ describe("diagnostics bundle", () => {
49
49
  expect(log).toContain("Authorization: Bearer [REDACTED]");
50
50
  expect(log).toContain('"refreshToken":"[REDACTED]"');
51
51
  }, 20_000);
52
+
53
+ it("bundles active log plus latest 5 rotated logs by default, or all with includeAllLogs", async () => {
54
+ const tmp = mkdtempSync(path.join(tmpdir(), "botcord-diag-logs-test-"));
55
+ const logFile = path.join(tmp, "daemon.log");
56
+ const configFile = path.join(tmp, "config.json");
57
+ const snapshotFile = path.join(tmp, "snapshot.json");
58
+ writeFileSync(logFile, "active\n");
59
+ writeFileSync(configFile, "{}\n");
60
+ writeFileSync(snapshotFile, "{}\n");
61
+ for (let i = 0; i < 7; i += 1) {
62
+ const rotated = path.join(tmp, `daemon.log.rot-${i}`);
63
+ writeFileSync(rotated, `rotated ${i}\n`);
64
+ const t = new Date(1_700_000_000_000 + i * 1000);
65
+ utimesSync(rotated, t, t);
66
+ }
67
+
68
+ const baseOpts = {
69
+ diagnosticsDir: path.join(tmp, "diagnostics"),
70
+ logFile,
71
+ configFile,
72
+ snapshotFile,
73
+ doctor: { text: "doctor ok", json: { ok: true } },
74
+ };
75
+ const bundle = await createDiagnosticBundle(baseOpts);
76
+ const listing = execFileSync("unzip", ["-l", bundle.path], { encoding: "utf8" });
77
+ expect(listing).toContain("daemon.log");
78
+ expect(listing).toContain("logs/daemon.log.rot-6");
79
+ expect(listing).toContain("logs/daemon.log.rot-2");
80
+ expect(listing).not.toContain("logs/daemon.log.rot-1");
81
+ expect(listing).not.toContain("logs/daemon.log.rot-0");
82
+
83
+ const full = await createDiagnosticBundle({ ...baseOpts, includeAllLogs: true });
84
+ const fullListing = execFileSync("unzip", ["-l", full.path], { encoding: "utf8" });
85
+ expect(fullListing).toContain("logs/daemon.log.rot-0");
86
+ expect(fullListing).toContain("logs/daemon.log.rot-6");
87
+ }, 20_000);
52
88
  });
@@ -240,6 +240,90 @@ describe("gateway_login_start / status", () => {
240
240
  expect(JSON.stringify(statusResult)).not.toContain("wechat-bot-token-1234567890");
241
241
  });
242
242
 
243
+ it("round-trips Feishu registration and upsert stores app secret locally", async () => {
244
+ const gw = makeFakeGateway();
245
+ const { state, io } = makeConfigIO(baseCfg());
246
+ const sessions = new LoginSessionStore();
247
+ const feishuLogin = {
248
+ startFeishuRegistration: vi.fn(async () => ({
249
+ deviceCode: "DEV-CODE",
250
+ verificationUriComplete: "https://accounts.feishu.cn/verify?x=1",
251
+ expiresIn: 600,
252
+ interval: 5,
253
+ domain: "feishu" as const,
254
+ raw: {},
255
+ })),
256
+ pollFeishuRegistration: vi.fn(async () => ({
257
+ status: "confirmed" as const,
258
+ appId: "cli_feishu_123",
259
+ appSecret: "feishu-secret-1234567890",
260
+ userOpenId: "ou_alice",
261
+ domain: "feishu" as const,
262
+ raw: {},
263
+ })),
264
+ };
265
+ const ctrl = createGatewayControl({
266
+ gateway: gw as any,
267
+ configIO: io,
268
+ loginSessions: sessions,
269
+ feishuLoginClient: feishuLogin,
270
+ });
271
+
272
+ const startAck = await ctrl.handleLoginStart({
273
+ provider: "feishu",
274
+ accountId: "ag_alice",
275
+ domain: "feishu",
276
+ });
277
+ expect(startAck.ok).toBe(true);
278
+ const startResult = startAck.result as { loginId: string; qrcodeUrl?: string };
279
+ expect(startResult.loginId).toMatch(/^fsl_/);
280
+ expect(startResult.qrcodeUrl).toBe("https://accounts.feishu.cn/verify?x=1");
281
+
282
+ const statusAck = await ctrl.handleLoginStatus({
283
+ provider: "feishu",
284
+ loginId: startResult.loginId,
285
+ accountId: "ag_alice",
286
+ });
287
+ expect(statusAck.ok).toBe(true);
288
+ const statusResult = statusAck.result as {
289
+ status: string;
290
+ appId?: string;
291
+ userOpenId?: string;
292
+ tokenPreview?: string;
293
+ };
294
+ expect(statusResult).toMatchObject({
295
+ status: "confirmed",
296
+ appId: "cli_feishu_123",
297
+ userOpenId: "ou_alice",
298
+ tokenPreview: "feis...7890",
299
+ });
300
+ expect(JSON.stringify(statusResult)).not.toContain("feishu-secret-1234567890");
301
+
302
+ const gwId = uniqId("fs");
303
+ const upsertAck = await ctrl.handleUpsert({
304
+ id: gwId,
305
+ type: "feishu",
306
+ accountId: "ag_alice",
307
+ enabled: true,
308
+ loginId: startResult.loginId,
309
+ settings: { allowedSenderIds: ["ou_alice"], domain: "feishu" },
310
+ });
311
+ expect(upsertAck.ok).toBe(true);
312
+ expect(state.cfg.thirdPartyGateways?.[0]).toMatchObject({
313
+ id: gwId,
314
+ type: "feishu",
315
+ appId: "cli_feishu_123",
316
+ domain: "feishu",
317
+ userOpenId: "ou_alice",
318
+ });
319
+ expect(gw.addChannel).toHaveBeenLastCalledWith(
320
+ expect.objectContaining({ id: gwId, type: "feishu", appId: "cli_feishu_123" }),
321
+ );
322
+ const secretPath = trackSecret(gwId);
323
+ const secret = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
324
+ expect(secret.appSecret).toBe("feishu-secret-1234567890");
325
+ });
326
+
243
327
  it("discovers recent WeChat senders from a confirmed login session", async () => {
244
328
  const gw = makeFakeGateway();
245
329
  const { io } = makeConfigIO(baseCfg());