@botcord/daemon 0.2.57 → 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.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.57",
3
+ "version": "0.2.59",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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";
@@ -25,6 +25,12 @@ describe("diagnostics bundle", () => {
25
25
  });
26
26
  expect(bundle.filename).toMatch(/^botcord-daemon-diagnostics-.*\.zip$/);
27
27
  expect(bundle.path).toContain(diagnosticsDir);
28
+ if (process.platform === "linux") {
29
+ expect(bundle.revealCommand).toContain(diagnosticsDir);
30
+ } else {
31
+ expect(bundle.revealCommand).toContain(bundle.path);
32
+ }
33
+ expect(bundle.copyPathCommand).toContain(bundle.path);
28
34
  expect(existsSync(bundle.path)).toBe(true);
29
35
  const bytes = readFileSync(bundle.path);
30
36
  expect(bytes.subarray(0, 4).toString("binary")).toBe("PK\u0003\u0004");
@@ -43,4 +49,40 @@ describe("diagnostics bundle", () => {
43
49
  expect(log).toContain("Authorization: Bearer [REDACTED]");
44
50
  expect(log).toContain('"refreshToken":"[REDACTED]"');
45
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);
46
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(
@@ -1080,7 +1127,7 @@ describe("W1: traceContexts hard cap", () => {
1080
1127
  rmSync(tmpCap, { recursive: true, force: true });
1081
1128
  });
1082
1129
 
1083
- it("inserting 5001 entries keeps the map at <= 5000 (oldest pruned)", async () => {
1130
+ it("keeps trace context map at the configured cap (oldest pruned)", async () => {
1084
1131
  // Build an adapter with a fake clock so we can control updatedAt order.
1085
1132
  let nowMs = 1_000_000;
1086
1133
  const fetchImpl = buildFetchStub(
@@ -1088,8 +1135,9 @@ describe("W1: traceContexts hard cap", () => {
1088
1135
  {
1089
1136
  match: "getupdates",
1090
1137
  respond: (idx) => {
1091
- if (idx < 5001) {
1092
- // Each poll returns one message so we get 5001 trace entries.
1138
+ if (idx < 3) {
1139
+ // Each poll returns one message so we get one more entry than
1140
+ // the test cap without looping 5001 times in CI.
1093
1141
  nowMs += 1;
1094
1142
  return {
1095
1143
  body: {
@@ -1106,7 +1154,7 @@ describe("W1: traceContexts hard cap", () => {
1106
1154
  },
1107
1155
  };
1108
1156
  }
1109
- return { body: { ret: 0, get_updates_buf: `buf-5001`, msgs: [] } };
1157
+ return { body: { ret: 0, get_updates_buf: `buf-3`, msgs: [] } };
1110
1158
  },
1111
1159
  },
1112
1160
  ],
@@ -1121,11 +1169,12 @@ describe("W1: traceContexts hard cap", () => {
1121
1169
  stateDebounceMs: 0,
1122
1170
  allowedSenderIds: ["alice@im.wechat"],
1123
1171
  now: () => nowMs,
1172
+ traceContextMax: 2,
1124
1173
  });
1125
- const h = startAdapter(adapter, { stopAfterEnvelopes: 5001 });
1174
+ const h = startAdapter(adapter, { stopAfterEnvelopes: 3 });
1126
1175
  await h.pollDone;
1127
- // 5001 messages were accepted; the cap should have kept the map <= 5000.
1128
- expect(h.envelopes.length).toBe(5001);
1176
+ // 3 messages were accepted; the cap should have kept the map <= 2.
1177
+ expect(h.envelopes.length).toBe(3);
1129
1178
  // We can't read traceContexts directly, but we verify that the send() for
1130
1179
  // the very first trace ID now fails (it was evicted as the oldest entry).
1131
1180
  const firstTraceId = h.envelopes[0]!.message.trace!.id;
@@ -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 {
@@ -43,6 +45,8 @@ export interface DiagnosticBundleResult {
43
45
  filename: string;
44
46
  sizeBytes: number;
45
47
  createdAt: string;
48
+ revealCommand: string;
49
+ copyPathCommand: string;
46
50
  }
47
51
 
48
52
  export interface DiagnosticUploadResult {
@@ -242,6 +246,45 @@ function createZip(entries: Array<{ name: string; data: string | Buffer }>): Buf
242
246
  return Buffer.concat([...localParts, central, end]);
243
247
  }
244
248
 
249
+ function shellQuote(s: string): string {
250
+ return `'${s.replace(/'/g, `'\\''`)}'`;
251
+ }
252
+
253
+ function diagnosticBundleCommands(filePath: string): {
254
+ revealCommand: string;
255
+ copyPathCommand: string;
256
+ } {
257
+ if (process.platform === "darwin") {
258
+ return {
259
+ revealCommand: `open -R ${shellQuote(filePath)}`,
260
+ copyPathCommand: `printf '%s' ${shellQuote(filePath)} | pbcopy`,
261
+ };
262
+ }
263
+
264
+ if (process.platform === "win32") {
265
+ const psPath = filePath.replace(/'/g, "''");
266
+ return {
267
+ revealCommand: `explorer.exe /select,"${filePath.replace(/"/g, '""')}"`,
268
+ copyPathCommand: `powershell.exe -NoProfile -Command "Set-Clipboard -Value '${psPath}'"`,
269
+ };
270
+ }
271
+
272
+ return {
273
+ revealCommand: `xdg-open ${shellQuote(path.dirname(filePath))}`,
274
+ copyPathCommand: `printf '%s' ${shellQuote(filePath)} | xclip -selection clipboard`,
275
+ };
276
+ }
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
+
245
288
  export async function createDiagnosticBundle(
246
289
  opts: CreateDiagnosticBundleOptions = {},
247
290
  ): Promise<DiagnosticBundleResult> {
@@ -252,6 +295,8 @@ export async function createDiagnosticBundle(
252
295
  const logFile = opts.logFile ?? LOG_FILE_PATH;
253
296
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
254
297
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
298
+ const includeAllLogs = opts.includeAllLogs === true;
299
+ const logs = bundledLogs(logFile, includeAllLogs);
255
300
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
256
301
 
257
302
  const doctor = opts.doctor ?? await buildDoctorEntries();
@@ -267,6 +312,13 @@ export async function createDiagnosticBundle(
267
312
  configPath: configFile,
268
313
  snapshotPath: snapshotFile,
269
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`,
270
322
  diagnosticsDir,
271
323
  userAuth: readUserAuthSummary(),
272
324
  };
@@ -277,11 +329,20 @@ export async function createDiagnosticBundle(
277
329
  { name: "doctor.txt", data: doctor.text + "\n" },
278
330
  { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
279
331
  ];
280
- const log = safeReadText(logFile);
281
- entries.push({
282
- name: "daemon.log",
283
- data: log ?? `no log file at ${logFile}\n`,
284
- });
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
+ }
285
346
  const config = safeReadText(configFile);
286
347
  entries.push({
287
348
  name: "config.json.redacted",
@@ -296,11 +357,13 @@ export async function createDiagnosticBundle(
296
357
  const zip = createZip(entries);
297
358
  const out = path.join(diagnosticsDir, filename);
298
359
  writeFileSync(out, zip, { mode: 0o600 });
360
+ const commands = diagnosticBundleCommands(out);
299
361
  return {
300
362
  path: out,
301
363
  filename,
302
364
  sizeBytes: zip.length,
303
365
  createdAt: createdAt.toISOString(),
366
+ ...commands,
304
367
  };
305
368
  }
306
369
 
@@ -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();
@@ -65,6 +65,8 @@ export interface WechatChannelOptions {
65
65
  stateDebounceMs?: number;
66
66
  /** Test hook: override Date.now() for trace cache TTL assertions. */
67
67
  now?: () => number;
68
+ /** Test hook: override trace context cache cap without a 5000-poll test. */
69
+ traceContextMax?: number;
68
70
  }
69
71
 
70
72
  interface WechatSecret {
@@ -75,6 +77,9 @@ interface WechatSecret {
75
77
  interface WechatItem {
76
78
  type?: number;
77
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 };
78
83
  [k: string]: unknown;
79
84
  }
80
85
 
@@ -138,6 +143,10 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
138
143
  const fetchImpl: FetchLike =
139
144
  opts.fetchImpl ?? ((globalThis.fetch as unknown) as FetchLike);
140
145
  const now: () => number = opts.now ?? (() => Date.now());
146
+ const traceContextMax =
147
+ opts.traceContextMax && opts.traceContextMax > 0
148
+ ? opts.traceContextMax
149
+ : TRACE_CONTEXT_MAX;
141
150
 
142
151
  let botToken: string | undefined = opts.botToken;
143
152
  let stateStore: GatewayStateStore | null = null;
@@ -195,7 +204,7 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
195
204
 
196
205
  function rememberTrace(traceId: string, ctx: TraceContext): void {
197
206
  // W1: prune oldest entry by updatedAt when cap is reached.
198
- if (traceContexts.size >= TRACE_CONTEXT_MAX) {
207
+ if (traceContexts.size >= traceContextMax) {
199
208
  let oldestKey: string | undefined;
200
209
  let oldestAt = Infinity;
201
210
  for (const [k, v] of traceContexts) {
@@ -392,16 +401,40 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
392
401
  return parts.join("\n").trim();
393
402
  }
394
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
+
395
427
  function normalizeInbound(msg: WechatInboundMsg): GatewayInboundMessage | null {
396
428
  if (msg.message_type !== 1) return null;
397
429
  const fromUid = typeof msg.from_user_id === "string" ? msg.from_user_id : "";
398
430
  const contextToken = typeof msg.context_token === "string" ? msg.context_token : "";
399
431
  if (!fromUid || !contextToken) return null;
400
432
  const text = extractText(msg);
401
- if (!text) return null;
433
+ const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
434
+ if (!text && !multimodalSummary) return null;
402
435
  if (!allowedSenderIds.has(fromUid)) return null;
403
436
 
404
- const sanitized = sanitizeUntrustedContent(text);
437
+ const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
405
438
  const receivedAt = now();
406
439
  // W10: append randomUUID() to the fallback so two messages received in
407
440
  // the same millisecond can't collide. Trace id below already does this.