@botcord/daemon 0.2.58 → 0.2.59

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.
@@ -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
  });
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { formatLogLine } from "../log.js";
2
+ import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { formatLogLine, listDaemonLogFiles, rotateLogIfNeeded } from "../log.js";
3
6
 
4
7
  describe("formatLogLine", () => {
5
8
  it("renders compact text with level, message, details, and trailing timestamp", () => {
@@ -27,4 +30,28 @@ describe("formatLogLine", () => {
27
30
  '[INFO] botcord ws server error msg={"type":"error","code":503} ts=2026-05-01T00:22:07.131Z',
28
31
  );
29
32
  });
33
+
34
+ it("rotates oversized logs and keeps the newest 20 rotated files", () => {
35
+ const tmp = mkdtempSync(path.join(tmpdir(), "botcord-log-test-"));
36
+ try {
37
+ const logFile = path.join(tmp, "daemon.log");
38
+ writeFileSync(logFile, "active log line\n");
39
+ for (let i = 0; i < 20; i += 1) {
40
+ const rotated = path.join(tmp, `daemon.log.old-${String(i).padStart(2, "0")}`);
41
+ writeFileSync(rotated, `old ${i}\n`);
42
+ const t = new Date(1_700_000_000_000 + i * 1000);
43
+ utimesSync(rotated, t, t);
44
+ }
45
+
46
+ rotateLogIfNeeded(logFile, 1, 10, 20);
47
+ const logs = listDaemonLogFiles(logFile);
48
+ const rotated = logs.filter((entry) => !entry.active);
49
+
50
+ expect(rotated).toHaveLength(20);
51
+ expect(rotated.some((entry) => entry.name === "daemon.log.old-00")).toBe(false);
52
+ expect(rotated.some((entry) => entry.name.startsWith("daemon.log."))).toBe(true);
53
+ } finally {
54
+ rmSync(tmp, { recursive: true, force: true });
55
+ }
56
+ });
30
57
  });
@@ -222,6 +222,53 @@ describe("wechat channel adapter", () => {
222
222
  expect(JSON.parse(stateRaw).cursor).toBe("cursor-after-1");
223
223
  });
224
224
 
225
+ it("normalizes media-only inbound items so dispatcher can defer them", async () => {
226
+ const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
227
+ const fetchImpl = buildFetchStub(
228
+ [
229
+ {
230
+ match: "getupdates",
231
+ respond: (idx) => {
232
+ if (idx === 0) {
233
+ return {
234
+ body: {
235
+ ret: 0,
236
+ get_updates_buf: "cursor-after-media",
237
+ msgs: [
238
+ {
239
+ message_type: 1,
240
+ from_user_id: "alice@im.wechat",
241
+ context_token: "ctx-media",
242
+ client_id: "wechat-media-1",
243
+ item_list: [{ type: 4, file_item: { file_name: "report.pdf", len: 123 } }],
244
+ },
245
+ ],
246
+ },
247
+ };
248
+ }
249
+ return { body: { ret: 0, get_updates_buf: "cursor-after-media", msgs: [] } };
250
+ },
251
+ },
252
+ ],
253
+ calls,
254
+ );
255
+ const adapter = createWechatChannel({
256
+ id: "gw_wx_media",
257
+ accountId: "ag_test",
258
+ botToken: "tok-123",
259
+ stateFile: path.join(tmp, "state.json"),
260
+ fetchImpl,
261
+ stateDebounceMs: 0,
262
+ allowedSenderIds: ["alice@im.wechat"],
263
+ });
264
+ const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
265
+ await h.pollDone;
266
+
267
+ expect(h.envelopes).toHaveLength(1);
268
+ expect(h.envelopes[0]!.message.id).toBe("wechat-media-1");
269
+ expect(h.envelopes[0]!.message.text).toBe("[File: report.pdf]");
270
+ });
271
+
225
272
  it("drops messages missing context_token", async () => {
226
273
  const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
227
274
  const fetchImpl = buildFetchStub(
@@ -16,7 +16,7 @@ import {
16
16
  loadConfig,
17
17
  type DaemonConfig,
18
18
  } from "./config.js";
19
- import { LOG_FILE_PATH } from "./log.js";
19
+ import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
20
20
  import {
21
21
  channelsFromDaemonConfig,
22
22
  defaultHttpFetcher,
@@ -29,6 +29,7 @@ import { detectRuntimes } from "./adapters/runtimes.js";
29
29
 
30
30
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
31
31
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
32
+ const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
32
33
 
33
34
  export interface CreateDiagnosticBundleOptions {
34
35
  diagnosticsDir?: string;
@@ -36,6 +37,7 @@ export interface CreateDiagnosticBundleOptions {
36
37
  configFile?: string;
37
38
  snapshotFile?: string;
38
39
  doctor?: { text: string; json: unknown };
40
+ includeAllLogs?: boolean;
39
41
  }
40
42
 
41
43
  export interface DiagnosticBundleResult {
@@ -273,6 +275,16 @@ function diagnosticBundleCommands(filePath: string): {
273
275
  };
274
276
  }
275
277
 
278
+ function bundledLogs(logFile: string, includeAllLogs: boolean): LogFileEntry[] {
279
+ const all = listDaemonLogFiles(logFile);
280
+ const active = all.filter((entry) => entry.active);
281
+ const rotated = all.filter((entry) => !entry.active);
282
+ return [
283
+ ...active,
284
+ ...(includeAllLogs ? rotated : rotated.slice(0, DEFAULT_ROTATED_LOGS_IN_BUNDLE)),
285
+ ];
286
+ }
287
+
276
288
  export async function createDiagnosticBundle(
277
289
  opts: CreateDiagnosticBundleOptions = {},
278
290
  ): Promise<DiagnosticBundleResult> {
@@ -283,6 +295,8 @@ export async function createDiagnosticBundle(
283
295
  const logFile = opts.logFile ?? LOG_FILE_PATH;
284
296
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
285
297
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
298
+ const includeAllLogs = opts.includeAllLogs === true;
299
+ const logs = bundledLogs(logFile, includeAllLogs);
286
300
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
287
301
 
288
302
  const doctor = opts.doctor ?? await buildDoctorEntries();
@@ -298,6 +312,13 @@ export async function createDiagnosticBundle(
298
312
  configPath: configFile,
299
313
  snapshotPath: snapshotFile,
300
314
  logPath: logFile,
315
+ logsBundled: logs.map((entry) => ({
316
+ name: entry.name,
317
+ path: entry.path,
318
+ sizeBytes: entry.sizeBytes,
319
+ active: entry.active,
320
+ })),
321
+ logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
301
322
  diagnosticsDir,
302
323
  userAuth: readUserAuthSummary(),
303
324
  };
@@ -308,11 +329,20 @@ export async function createDiagnosticBundle(
308
329
  { name: "doctor.txt", data: doctor.text + "\n" },
309
330
  { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
310
331
  ];
311
- const log = safeReadText(logFile);
312
- entries.push({
313
- name: "daemon.log",
314
- data: log ?? `no log file at ${logFile}\n`,
315
- });
332
+ if (logs.length === 0) {
333
+ entries.push({
334
+ name: "daemon.log",
335
+ data: `no log file at ${logFile}\n`,
336
+ });
337
+ } else {
338
+ for (const entry of logs) {
339
+ const log = safeReadText(entry.path);
340
+ entries.push({
341
+ name: entry.active ? "daemon.log" : `logs/${entry.name}`,
342
+ data: log ?? `no log file at ${entry.path}\n`,
343
+ });
344
+ }
345
+ }
316
346
  const config = safeReadText(configFile);
317
347
  entries.push({
318
348
  name: "config.json.redacted",
@@ -362,6 +362,58 @@ describe("Dispatcher", () => {
362
362
  expect(runtime.calls[0].text).toBe("WRAPPED:hello");
363
363
  });
364
364
 
365
+ it("defers multimodal-only BotCord messages until the next text turn and preserves order", async () => {
366
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
367
+ const { store, dir } = await makeStore();
368
+ tempDirs.push(dir);
369
+ const channel = new FakeChannel();
370
+ const dispatcher = new Dispatcher({
371
+ config: baseConfig(),
372
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
373
+ runtime: () => runtime,
374
+ sessionStore: store,
375
+ log: silentLogger(),
376
+ composeUserTurn: (msg) => {
377
+ const raw = msg.raw as { batch?: Array<{ text?: string }> };
378
+ return (raw.batch ?? [{ text: msg.text }]).map((m) => m.text).join("\n");
379
+ },
380
+ });
381
+ const acceptMedia = vi.fn(async () => {});
382
+ const acceptText = vi.fn(async () => {});
383
+
384
+ await dispatcher.handle(makeEnvelope({
385
+ id: "h_media",
386
+ text: '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png',
387
+ raw: {
388
+ hub_msg_id: "h_media",
389
+ text: '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png',
390
+ envelope: {
391
+ type: "message",
392
+ payload: { attachments: [{ filename: "a.png", url: "/hub/files/f_1" }] },
393
+ },
394
+ },
395
+ }, { accept: acceptMedia }));
396
+
397
+ expect(acceptMedia).toHaveBeenCalledTimes(1);
398
+ expect(runtime.calls.length).toBe(0);
399
+
400
+ await dispatcher.handle(makeEnvelope({
401
+ id: "h_text",
402
+ text: "please inspect this",
403
+ raw: {
404
+ hub_msg_id: "h_text",
405
+ text: "please inspect this",
406
+ envelope: { type: "message", payload: { text: "please inspect this" } },
407
+ },
408
+ }, { accept: acceptText }));
409
+
410
+ expect(acceptText).toHaveBeenCalledTimes(1);
411
+ expect(runtime.calls.length).toBe(1);
412
+ expect(runtime.calls[0].text).toBe(
413
+ '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png\nplease inspect this',
414
+ );
415
+ });
416
+
365
417
  it("falls back to raw text when composeUserTurn throws", async () => {
366
418
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
367
419
  const { store, dir } = await makeStore();
@@ -77,6 +77,9 @@ interface WechatSecret {
77
77
  interface WechatItem {
78
78
  type?: number;
79
79
  text_item?: { text?: string };
80
+ image_item?: Record<string, unknown>;
81
+ file_item?: { file_name?: string; len?: unknown; [k: string]: unknown };
82
+ video_item?: { file_name?: string; video_size?: unknown; [k: string]: unknown };
80
83
  [k: string]: unknown;
81
84
  }
82
85
 
@@ -398,16 +401,40 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
398
401
  return parts.join("\n").trim();
399
402
  }
400
403
 
404
+ function extractMultimodalSummary(msg: WechatInboundMsg): string {
405
+ const parts: string[] = [];
406
+ for (const item of msg.item_list ?? []) {
407
+ if (!item || item.type === 1) continue;
408
+ if (item.type === 2) {
409
+ parts.push("[Image]");
410
+ continue;
411
+ }
412
+ if (item.type === 5) {
413
+ const name = item.video_item?.file_name;
414
+ parts.push(name ? `[Video: ${name}]` : "[Video]");
415
+ continue;
416
+ }
417
+ if (item.type === 4) {
418
+ const name = item.file_item?.file_name;
419
+ parts.push(name ? `[File: ${name}]` : "[File]");
420
+ continue;
421
+ }
422
+ parts.push(`[Unsupported media item: type=${String(item.type ?? "unknown")}]`);
423
+ }
424
+ return parts.join("\n").trim();
425
+ }
426
+
401
427
  function normalizeInbound(msg: WechatInboundMsg): GatewayInboundMessage | null {
402
428
  if (msg.message_type !== 1) return null;
403
429
  const fromUid = typeof msg.from_user_id === "string" ? msg.from_user_id : "";
404
430
  const contextToken = typeof msg.context_token === "string" ? msg.context_token : "";
405
431
  if (!fromUid || !contextToken) return null;
406
432
  const text = extractText(msg);
407
- if (!text) return null;
433
+ const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
434
+ if (!text && !multimodalSummary) return null;
408
435
  if (!allowedSenderIds.has(fromUid)) return null;
409
436
 
410
- const sanitized = sanitizeUntrustedContent(text);
437
+ const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
411
438
  const receivedAt = now();
412
439
  // W10: append randomUUID() to the fallback so two messages received in
413
440
  // the same millisecond can't collide. Trace id below already does this.