@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.
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- package/dist/gateway/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +186 -27
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/package.json +1 -1
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/wechat-channel.test.ts +47 -0
- package/src/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +52 -0
- package/src/gateway/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +212 -26
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
|
@@ -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 {
|
|
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(
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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.
|