@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
package/dist/diagnostics.d.ts
CHANGED
package/dist/diagnostics.js
CHANGED
|
@@ -5,11 +5,12 @@ import { Buffer } from "node:buffer";
|
|
|
5
5
|
import { deflateRawSync } from "node:zlib";
|
|
6
6
|
import { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
|
|
7
7
|
import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, } from "./config.js";
|
|
8
|
-
import { LOG_FILE_PATH } from "./log.js";
|
|
8
|
+
import { listDaemonLogFiles, LOG_FILE_PATH } from "./log.js";
|
|
9
9
|
import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
|
|
10
10
|
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
11
11
|
const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
|
|
12
12
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
13
|
+
const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
|
|
13
14
|
const SECRET_PATTERNS = [
|
|
14
15
|
[/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
|
|
15
16
|
[/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
|
|
@@ -211,6 +212,15 @@ function diagnosticBundleCommands(filePath) {
|
|
|
211
212
|
copyPathCommand: `printf '%s' ${shellQuote(filePath)} | xclip -selection clipboard`,
|
|
212
213
|
};
|
|
213
214
|
}
|
|
215
|
+
function bundledLogs(logFile, includeAllLogs) {
|
|
216
|
+
const all = listDaemonLogFiles(logFile);
|
|
217
|
+
const active = all.filter((entry) => entry.active);
|
|
218
|
+
const rotated = all.filter((entry) => !entry.active);
|
|
219
|
+
return [
|
|
220
|
+
...active,
|
|
221
|
+
...(includeAllLogs ? rotated : rotated.slice(0, DEFAULT_ROTATED_LOGS_IN_BUNDLE)),
|
|
222
|
+
];
|
|
223
|
+
}
|
|
214
224
|
export async function createDiagnosticBundle(opts = {}) {
|
|
215
225
|
const createdAt = new Date();
|
|
216
226
|
const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
|
|
@@ -219,6 +229,8 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
219
229
|
const logFile = opts.logFile ?? LOG_FILE_PATH;
|
|
220
230
|
const configFile = opts.configFile ?? CONFIG_FILE_PATH;
|
|
221
231
|
const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
|
|
232
|
+
const includeAllLogs = opts.includeAllLogs === true;
|
|
233
|
+
const logs = bundledLogs(logFile, includeAllLogs);
|
|
222
234
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
223
235
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
224
236
|
const status = {
|
|
@@ -233,6 +245,13 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
233
245
|
configPath: configFile,
|
|
234
246
|
snapshotPath: snapshotFile,
|
|
235
247
|
logPath: logFile,
|
|
248
|
+
logsBundled: logs.map((entry) => ({
|
|
249
|
+
name: entry.name,
|
|
250
|
+
path: entry.path,
|
|
251
|
+
sizeBytes: entry.sizeBytes,
|
|
252
|
+
active: entry.active,
|
|
253
|
+
})),
|
|
254
|
+
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
236
255
|
diagnosticsDir,
|
|
237
256
|
userAuth: readUserAuthSummary(),
|
|
238
257
|
};
|
|
@@ -242,11 +261,21 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
242
261
|
{ name: "doctor.txt", data: doctor.text + "\n" },
|
|
243
262
|
{ name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
|
|
244
263
|
];
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
264
|
+
if (logs.length === 0) {
|
|
265
|
+
entries.push({
|
|
266
|
+
name: "daemon.log",
|
|
267
|
+
data: `no log file at ${logFile}\n`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
for (const entry of logs) {
|
|
272
|
+
const log = safeReadText(entry.path);
|
|
273
|
+
entries.push({
|
|
274
|
+
name: entry.active ? "daemon.log" : `logs/${entry.name}`,
|
|
275
|
+
data: log ?? `no log file at ${entry.path}\n`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
250
279
|
const config = safeReadText(configFile);
|
|
251
280
|
entries.push({
|
|
252
281
|
name: "config.json.redacted",
|
|
@@ -285,6 +285,29 @@ export function createWechatChannel(opts) {
|
|
|
285
285
|
}
|
|
286
286
|
return parts.join("\n").trim();
|
|
287
287
|
}
|
|
288
|
+
function extractMultimodalSummary(msg) {
|
|
289
|
+
const parts = [];
|
|
290
|
+
for (const item of msg.item_list ?? []) {
|
|
291
|
+
if (!item || item.type === 1)
|
|
292
|
+
continue;
|
|
293
|
+
if (item.type === 2) {
|
|
294
|
+
parts.push("[Image]");
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (item.type === 5) {
|
|
298
|
+
const name = item.video_item?.file_name;
|
|
299
|
+
parts.push(name ? `[Video: ${name}]` : "[Video]");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (item.type === 4) {
|
|
303
|
+
const name = item.file_item?.file_name;
|
|
304
|
+
parts.push(name ? `[File: ${name}]` : "[File]");
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
parts.push(`[Unsupported media item: type=${String(item.type ?? "unknown")}]`);
|
|
308
|
+
}
|
|
309
|
+
return parts.join("\n").trim();
|
|
310
|
+
}
|
|
288
311
|
function normalizeInbound(msg) {
|
|
289
312
|
if (msg.message_type !== 1)
|
|
290
313
|
return null;
|
|
@@ -293,11 +316,12 @@ export function createWechatChannel(opts) {
|
|
|
293
316
|
if (!fromUid || !contextToken)
|
|
294
317
|
return null;
|
|
295
318
|
const text = extractText(msg);
|
|
296
|
-
|
|
319
|
+
const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
|
|
320
|
+
if (!text && !multimodalSummary)
|
|
297
321
|
return null;
|
|
298
322
|
if (!allowedSenderIds.has(fromUid))
|
|
299
323
|
return null;
|
|
300
|
-
const sanitized = sanitizeUntrustedContent(text);
|
|
324
|
+
const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
|
|
301
325
|
const receivedAt = now();
|
|
302
326
|
// W10: append randomUUID() to the fallback so two messages received in
|
|
303
327
|
// the same millisecond can't collide. Trace id below already does this.
|
|
@@ -94,6 +94,7 @@ export declare class Dispatcher {
|
|
|
94
94
|
private readonly resolveHubUrl?;
|
|
95
95
|
private readonly transcript;
|
|
96
96
|
private readonly queues;
|
|
97
|
+
private readonly deferredMultimodal;
|
|
97
98
|
/**
|
|
98
99
|
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
99
100
|
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
@@ -107,6 +108,8 @@ export declare class Dispatcher {
|
|
|
107
108
|
turns(): Record<string, TurnStatusSnapshot>;
|
|
108
109
|
private safeAck;
|
|
109
110
|
private getQueue;
|
|
111
|
+
private deferMultimodal;
|
|
112
|
+
private takeDeferredMultimodal;
|
|
110
113
|
private runCancelPrevious;
|
|
111
114
|
/**
|
|
112
115
|
* Serial mode with coalesce-on-drain semantics:
|
|
@@ -78,6 +78,7 @@ export class Dispatcher {
|
|
|
78
78
|
resolveHubUrl;
|
|
79
79
|
transcript;
|
|
80
80
|
queues = new Map();
|
|
81
|
+
deferredMultimodal = new Map();
|
|
81
82
|
/**
|
|
82
83
|
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
83
84
|
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
@@ -125,6 +126,10 @@ export class Dispatcher {
|
|
|
125
126
|
await this.safeAck(envelope);
|
|
126
127
|
return;
|
|
127
128
|
}
|
|
129
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
130
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
131
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
132
|
+
const queueKey = buildQueueKey(msg);
|
|
128
133
|
// Pre-skip: empty/whitespace text.
|
|
129
134
|
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
130
135
|
if (!rawText) {
|
|
@@ -135,20 +140,76 @@ export class Dispatcher {
|
|
|
135
140
|
// From here on, the inbound is a real conversation event — generate a
|
|
136
141
|
// turnId and write the inbound transcript record.
|
|
137
142
|
const turnId = randomUUID();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
// Multimodal-only arrivals (files/images without sender-authored text)
|
|
144
|
+
// should not wake the runtime on their own. Ack them, record the inbound
|
|
145
|
+
// event, and prepend them to the next text-bearing turn for this queue.
|
|
146
|
+
if (isMultimodalOnlyMessage(msg)) {
|
|
147
|
+
await this.safeAck(envelope);
|
|
148
|
+
this.emitInbound(turnId, msg);
|
|
149
|
+
this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
|
|
150
|
+
this.log.info("dispatcher: deferred multimodal-only inbound", {
|
|
151
|
+
agentId: msg.accountId,
|
|
152
|
+
roomId: msg.conversation.id,
|
|
153
|
+
topicId: msg.conversation.threadId ?? null,
|
|
154
|
+
turnId,
|
|
155
|
+
messageId: msg.id,
|
|
156
|
+
senderId: msg.sender.id,
|
|
157
|
+
senderKind: msg.sender.kind,
|
|
158
|
+
mode,
|
|
159
|
+
queueKey,
|
|
160
|
+
});
|
|
161
|
+
if (this.onInbound) {
|
|
162
|
+
try {
|
|
163
|
+
await this.onInbound(msg);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
this.log.warn("dispatcher: onInbound threw — continuing", {
|
|
167
|
+
messageId: msg.id,
|
|
168
|
+
error: err instanceof Error ? err.message : String(err),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const deferred = this.takeDeferredMultimodal(queueKey);
|
|
175
|
+
let dispatchMsg = msg;
|
|
176
|
+
let dispatchTurnId = turnId;
|
|
177
|
+
let dispatchRoute = route;
|
|
178
|
+
let dispatchChannel = channel;
|
|
179
|
+
let text = rawText;
|
|
180
|
+
let mergedFromDeferredTurnIds = [];
|
|
181
|
+
if (deferred.length > 0) {
|
|
182
|
+
const merged = this.mergeSerialBuffer([...deferred, { route, msg, channel, turnId }], queueKey);
|
|
183
|
+
if (merged) {
|
|
184
|
+
dispatchMsg = merged.msg;
|
|
185
|
+
dispatchTurnId = merged.turnId;
|
|
186
|
+
dispatchRoute = merged.route;
|
|
187
|
+
dispatchChannel = merged.channel;
|
|
188
|
+
text = merged.text;
|
|
189
|
+
mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
|
|
190
|
+
for (const entry of deferred) {
|
|
191
|
+
this.transcript.write({
|
|
192
|
+
ts: nowIso(),
|
|
193
|
+
kind: "dropped",
|
|
194
|
+
turnId: entry.turnId,
|
|
195
|
+
agentId: entry.msg.accountId,
|
|
196
|
+
roomId: entry.msg.conversation.id,
|
|
197
|
+
topicId: entry.msg.conversation.threadId ?? null,
|
|
198
|
+
reason: "batch_merged",
|
|
199
|
+
supersededBy: dispatchTurnId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
142
204
|
// Compose the final user-turn text only for cancel-previous mode, where
|
|
143
205
|
// the dispatcher consumes the pre-composed text directly. Serial mode
|
|
144
206
|
// re-runs the composer at drain time on the merged message (so it sees
|
|
145
207
|
// the full coalesced batch instead of any single arrival), so calling
|
|
146
208
|
// the composer here would just be redundant work.
|
|
147
|
-
let text = rawText;
|
|
148
209
|
let composeFailedError;
|
|
149
210
|
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
150
211
|
try {
|
|
151
|
-
const composed = this.composeUserTurn(
|
|
212
|
+
const composed = this.composeUserTurn(dispatchMsg);
|
|
152
213
|
if (typeof composed === "string" && composed.length > 0) {
|
|
153
214
|
text = composed;
|
|
154
215
|
}
|
|
@@ -156,7 +217,7 @@ export class Dispatcher {
|
|
|
156
217
|
catch (err) {
|
|
157
218
|
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
158
219
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
159
|
-
messageId:
|
|
220
|
+
messageId: dispatchMsg.id,
|
|
160
221
|
error: composeFailedError,
|
|
161
222
|
});
|
|
162
223
|
}
|
|
@@ -197,29 +258,29 @@ export class Dispatcher {
|
|
|
197
258
|
if (this.attentionGate) {
|
|
198
259
|
let wake = true;
|
|
199
260
|
try {
|
|
200
|
-
const result = this.attentionGate(
|
|
261
|
+
const result = this.attentionGate(dispatchMsg);
|
|
201
262
|
wake = result instanceof Promise ? await result : result;
|
|
202
263
|
}
|
|
203
264
|
catch (err) {
|
|
204
265
|
this.log.warn("dispatcher: attentionGate threw — waking", {
|
|
205
|
-
messageId:
|
|
266
|
+
messageId: dispatchMsg.id,
|
|
206
267
|
error: err instanceof Error ? err.message : String(err),
|
|
207
268
|
});
|
|
208
269
|
wake = true;
|
|
209
270
|
}
|
|
210
271
|
if (!wake) {
|
|
211
272
|
this.log.debug("dispatcher skip turn: attention policy", {
|
|
212
|
-
messageId:
|
|
213
|
-
accountId:
|
|
214
|
-
conversationId:
|
|
273
|
+
messageId: dispatchMsg.id,
|
|
274
|
+
accountId: dispatchMsg.accountId,
|
|
275
|
+
conversationId: dispatchMsg.conversation.id,
|
|
215
276
|
});
|
|
216
277
|
this.transcript.write({
|
|
217
278
|
ts: nowIso(),
|
|
218
279
|
kind: "attention_skipped",
|
|
219
|
-
turnId,
|
|
220
|
-
agentId:
|
|
221
|
-
roomId:
|
|
222
|
-
topicId:
|
|
280
|
+
turnId: dispatchTurnId,
|
|
281
|
+
agentId: dispatchMsg.accountId,
|
|
282
|
+
roomId: dispatchMsg.conversation.id,
|
|
283
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
223
284
|
reason: "attention_gate_false",
|
|
224
285
|
});
|
|
225
286
|
return;
|
|
@@ -229,19 +290,19 @@ export class Dispatcher {
|
|
|
229
290
|
this.transcript.write({
|
|
230
291
|
ts: nowIso(),
|
|
231
292
|
kind: "compose_failed",
|
|
232
|
-
turnId,
|
|
233
|
-
agentId:
|
|
234
|
-
roomId:
|
|
235
|
-
topicId:
|
|
293
|
+
turnId: dispatchTurnId,
|
|
294
|
+
agentId: dispatchMsg.accountId,
|
|
295
|
+
roomId: dispatchMsg.conversation.id,
|
|
296
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
236
297
|
error: composeFailedError,
|
|
237
298
|
fallback: "raw_text",
|
|
238
299
|
});
|
|
239
300
|
}
|
|
240
301
|
if (mode === "cancel-previous") {
|
|
241
|
-
await this.runCancelPrevious(queueKey,
|
|
302
|
+
await this.runCancelPrevious(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
|
|
242
303
|
}
|
|
243
304
|
else {
|
|
244
|
-
await this.runSerial(queueKey,
|
|
305
|
+
await this.runSerial(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
|
|
245
306
|
}
|
|
246
307
|
}
|
|
247
308
|
/** Snapshot of currently running turns keyed by queue key. */
|
|
@@ -283,7 +344,37 @@ export class Dispatcher {
|
|
|
283
344
|
}
|
|
284
345
|
return q;
|
|
285
346
|
}
|
|
286
|
-
|
|
347
|
+
deferMultimodal(queueKey, entry) {
|
|
348
|
+
const list = this.deferredMultimodal.get(queueKey) ?? [];
|
|
349
|
+
list.push(entry);
|
|
350
|
+
while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
351
|
+
const dropped = list.shift();
|
|
352
|
+
this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
|
|
353
|
+
queueKey,
|
|
354
|
+
droppedMessageId: dropped.msg.id,
|
|
355
|
+
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
356
|
+
});
|
|
357
|
+
this.transcript.write({
|
|
358
|
+
ts: nowIso(),
|
|
359
|
+
kind: "dropped",
|
|
360
|
+
turnId: dropped.turnId,
|
|
361
|
+
agentId: dropped.msg.accountId,
|
|
362
|
+
roomId: dropped.msg.conversation.id,
|
|
363
|
+
topicId: dropped.msg.conversation.threadId ?? null,
|
|
364
|
+
reason: "queue_overflow",
|
|
365
|
+
supersededBy: null,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
this.deferredMultimodal.set(queueKey, list);
|
|
369
|
+
}
|
|
370
|
+
takeDeferredMultimodal(queueKey) {
|
|
371
|
+
const list = this.deferredMultimodal.get(queueKey);
|
|
372
|
+
if (!list || list.length === 0)
|
|
373
|
+
return [];
|
|
374
|
+
this.deferredMultimodal.delete(queueKey);
|
|
375
|
+
return list;
|
|
376
|
+
}
|
|
377
|
+
async runCancelPrevious(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds = []) {
|
|
287
378
|
const q = this.getQueue(queueKey);
|
|
288
379
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
289
380
|
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
@@ -342,7 +433,7 @@ export class Dispatcher {
|
|
|
342
433
|
});
|
|
343
434
|
return;
|
|
344
435
|
}
|
|
345
|
-
await this.runTurn(queueKey, route, text, msg, channel, turnId,
|
|
436
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
|
|
346
437
|
}
|
|
347
438
|
/**
|
|
348
439
|
* Serial mode with coalesce-on-drain semantics:
|
|
@@ -362,7 +453,7 @@ export class Dispatcher {
|
|
|
362
453
|
* merged message so the runtime sees a single coherent prompt covering all
|
|
363
454
|
* coalesced messages.
|
|
364
455
|
*/
|
|
365
|
-
async runSerial(queueKey, route, _text, msg, channel, turnId) {
|
|
456
|
+
async runSerial(queueKey, route, _text, msg, channel, turnId, mergedFromTurnIds = []) {
|
|
366
457
|
const q = this.getQueue(queueKey);
|
|
367
458
|
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
368
459
|
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
@@ -409,8 +500,10 @@ export class Dispatcher {
|
|
|
409
500
|
});
|
|
410
501
|
}
|
|
411
502
|
}
|
|
412
|
-
const
|
|
413
|
-
|
|
503
|
+
const mergedTurnIds = drained.length > 1
|
|
504
|
+
? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
|
|
505
|
+
: mergedFromTurnIds;
|
|
506
|
+
await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedTurnIds);
|
|
414
507
|
}
|
|
415
508
|
}
|
|
416
509
|
finally {
|
|
@@ -478,8 +571,13 @@ export class Dispatcher {
|
|
|
478
571
|
const latestRaw = latest.msg.raw ?? {};
|
|
479
572
|
const mergedRaw = { ...latestRaw, batch: items };
|
|
480
573
|
const anyMentioned = entries.some((e) => e.msg.mentioned === true);
|
|
574
|
+
const mergedText = entries
|
|
575
|
+
.map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
|
|
576
|
+
.filter((s) => s.length > 0)
|
|
577
|
+
.join("\n");
|
|
481
578
|
const mergedMsg = {
|
|
482
579
|
...latest.msg,
|
|
580
|
+
...(mergedText ? { text: mergedText } : {}),
|
|
483
581
|
mentioned: anyMentioned,
|
|
484
582
|
raw: mergedRaw,
|
|
485
583
|
};
|
|
@@ -1296,6 +1394,67 @@ function isOwnerChatRoom(msg) {
|
|
|
1296
1394
|
function isBotCordChannel(channel) {
|
|
1297
1395
|
return channel.type === "botcord" || channel.id === "botcord";
|
|
1298
1396
|
}
|
|
1397
|
+
function isMultimodalOnlyMessage(msg) {
|
|
1398
|
+
if (!hasMultimodalContent(msg.raw))
|
|
1399
|
+
return false;
|
|
1400
|
+
return !hasAuthoredText(msg.raw);
|
|
1401
|
+
}
|
|
1402
|
+
function hasAuthoredText(raw) {
|
|
1403
|
+
if (!raw || typeof raw !== "object")
|
|
1404
|
+
return false;
|
|
1405
|
+
const obj = raw;
|
|
1406
|
+
const batch = obj.batch;
|
|
1407
|
+
if (Array.isArray(batch))
|
|
1408
|
+
return batch.some((item) => hasAuthoredText(item));
|
|
1409
|
+
if (typeof obj.text === "string" && obj.text.trim().length > 0) {
|
|
1410
|
+
// BotCord's /hub/inbox `text` may be synthesized from attachment metadata
|
|
1411
|
+
// when payload text is empty, so prefer envelope payload below when present.
|
|
1412
|
+
if (!obj.envelope || typeof obj.envelope !== "object")
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
const envelope = obj.envelope;
|
|
1416
|
+
const payload = envelope?.payload;
|
|
1417
|
+
if (payload) {
|
|
1418
|
+
for (const key of ["text", "body", "message"]) {
|
|
1419
|
+
const value = payload[key];
|
|
1420
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
const itemList = obj.item_list;
|
|
1426
|
+
if (Array.isArray(itemList)) {
|
|
1427
|
+
return itemList.some((item) => {
|
|
1428
|
+
if (!item || typeof item !== "object")
|
|
1429
|
+
return false;
|
|
1430
|
+
const textItem = item.text_item;
|
|
1431
|
+
return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
return typeof obj.text === "string" && obj.text.trim().length > 0;
|
|
1435
|
+
}
|
|
1436
|
+
function hasMultimodalContent(raw) {
|
|
1437
|
+
if (!raw || typeof raw !== "object")
|
|
1438
|
+
return false;
|
|
1439
|
+
const obj = raw;
|
|
1440
|
+
const batch = obj.batch;
|
|
1441
|
+
if (Array.isArray(batch))
|
|
1442
|
+
return batch.some((item) => hasMultimodalContent(item));
|
|
1443
|
+
const envelope = obj.envelope;
|
|
1444
|
+
const payload = envelope?.payload;
|
|
1445
|
+
const attachments = payload?.attachments;
|
|
1446
|
+
if (Array.isArray(attachments) && attachments.length > 0)
|
|
1447
|
+
return true;
|
|
1448
|
+
const itemList = obj.item_list;
|
|
1449
|
+
if (Array.isArray(itemList)) {
|
|
1450
|
+
return itemList.some((item) => {
|
|
1451
|
+
if (!item || typeof item !== "object")
|
|
1452
|
+
return false;
|
|
1453
|
+
return item.type !== 1;
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1299
1458
|
function resolveQueueMode(route, kind) {
|
|
1300
1459
|
if (route.queueMode)
|
|
1301
1460
|
return route.queueMode;
|
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]
|
|
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 {
|