@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/diagnostics.d.ts +3 -0
- package/dist/diagnostics.js +59 -6
- package/dist/gateway/channels/wechat.d.ts +2 -0
- package/dist/gateway/channels/wechat.js +30 -3
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +186 -27
- package/dist/index.js +11 -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 +43 -1
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/wechat-channel.test.ts +56 -7
- package/src/diagnostics.ts +69 -6
- package/src/gateway/__tests__/dispatcher.test.ts +52 -0
- package/src/gateway/channels/wechat.ts +36 -3
- package/src/gateway/dispatcher.ts +212 -26
- package/src/index.ts +11 -3
- package/src/log.ts +100 -1
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,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 {
|
|
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("
|
|
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 <
|
|
1092
|
-
// Each poll returns one message so we get
|
|
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-
|
|
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:
|
|
1174
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 3 });
|
|
1126
1175
|
await h.pollDone;
|
|
1127
|
-
//
|
|
1128
|
-
expect(h.envelopes.length).toBe(
|
|
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;
|
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 {
|
|
@@ -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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 >=
|
|
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
|
-
|
|
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.
|