@botcord/daemon 0.2.58 → 0.2.60
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/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +190 -30
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/__tests__/wechat-channel.test.ts +47 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +62 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +216 -29
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +1 -1
- package/src/gateway-control.ts +188 -17
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
- package/src/provision.ts +13 -1
|
@@ -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
|
});
|
|
@@ -48,16 +48,13 @@ function makeFakeGateway(): unknown {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function makeFakeResolver()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
resolve: ReturnType<typeof vi.fn>;
|
|
55
|
-
} {
|
|
56
|
-
return {
|
|
57
|
-
resolve: vi.fn(async () => ({ mode: "always", keywords: [] })),
|
|
51
|
+
function makeFakeResolver() {
|
|
52
|
+
const resolver = {
|
|
53
|
+
resolve: vi.fn(async () => ({ mode: "always" as const, keywords: [] })),
|
|
58
54
|
invalidate: vi.fn(),
|
|
59
55
|
put: vi.fn(),
|
|
60
56
|
};
|
|
57
|
+
return resolver as PolicyResolverLike & typeof resolver;
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
describe("policy_updated control-frame handler", () => {
|
|
@@ -110,6 +107,7 @@ describe("policy_updated control-frame handler", () => {
|
|
|
110
107
|
expect(resolver.put).toHaveBeenCalledWith("ag_a", null, {
|
|
111
108
|
mode: "keyword",
|
|
112
109
|
keywords: ["foo", "bar"],
|
|
110
|
+
allowedSenderIds: [],
|
|
113
111
|
muted_until: 123,
|
|
114
112
|
});
|
|
115
113
|
expect(resolver.invalidate).not.toHaveBeenCalled();
|
|
@@ -37,6 +37,15 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
|
|
|
37
37
|
allowedSenderIds: ["abc@im.wechat"],
|
|
38
38
|
splitAt: 1800,
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
id: "gw_fs_1",
|
|
42
|
+
type: "feishu",
|
|
43
|
+
accountId: "ag_daemon",
|
|
44
|
+
appId: "cli_xxx",
|
|
45
|
+
domain: "feishu",
|
|
46
|
+
allowedSenderIds: ["ou_alice"],
|
|
47
|
+
allowedChatIds: ["oc_team"],
|
|
48
|
+
},
|
|
40
49
|
],
|
|
41
50
|
});
|
|
42
51
|
const gw = toGatewayConfig(cfg);
|
|
@@ -44,6 +53,7 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
|
|
|
44
53
|
{ id: "ag_daemon", type: BOTCORD_CHANNEL_TYPE },
|
|
45
54
|
{ id: "gw_tg_1", type: TELEGRAM_CHANNEL_TYPE },
|
|
46
55
|
{ id: "gw_wx_1", type: WECHAT_CHANNEL_TYPE },
|
|
56
|
+
{ id: "gw_fs_1", type: "feishu" },
|
|
47
57
|
]);
|
|
48
58
|
const tg = gw.channels[1]!;
|
|
49
59
|
expect(tg.accountId).toBe("ag_daemon");
|
|
@@ -52,6 +62,11 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
|
|
|
52
62
|
expect(wx.baseUrl).toBe("https://ilinkai.weixin.qq.com");
|
|
53
63
|
expect(wx.allowedSenderIds).toEqual(["abc@im.wechat"]);
|
|
54
64
|
expect(wx.splitAt).toBe(1800);
|
|
65
|
+
const fs = gw.channels[3]!;
|
|
66
|
+
expect(fs.appId).toBe("cli_xxx");
|
|
67
|
+
expect(fs.domain).toBe("feishu");
|
|
68
|
+
expect(fs.allowedSenderIds).toEqual(["ou_alice"]);
|
|
69
|
+
expect(fs.allowedChatIds).toEqual(["oc_team"]);
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
it("filters out gateways with enabled === false", () => {
|
|
@@ -115,6 +130,19 @@ describe("createDaemonChannel", () => {
|
|
|
115
130
|
expect(adapter.id).toBe("gw_wx_1");
|
|
116
131
|
});
|
|
117
132
|
|
|
133
|
+
it("dispatches feishu type to the Feishu adapter", () => {
|
|
134
|
+
const chCfg: GatewayChannelConfig = {
|
|
135
|
+
id: "gw_fs_1",
|
|
136
|
+
type: "feishu",
|
|
137
|
+
accountId: "ag_x",
|
|
138
|
+
appId: "cli_xxx",
|
|
139
|
+
domain: "feishu",
|
|
140
|
+
};
|
|
141
|
+
const adapter = createDaemonChannel(chCfg, deps);
|
|
142
|
+
expect(adapter.type).toBe("feishu");
|
|
143
|
+
expect(adapter.id).toBe("gw_fs_1");
|
|
144
|
+
});
|
|
145
|
+
|
|
118
146
|
it("throws on unknown channel type", () => {
|
|
119
147
|
const chCfg: GatewayChannelConfig = {
|
|
120
148
|
id: "gw_x",
|
|
@@ -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/config.ts
CHANGED
|
@@ -100,7 +100,7 @@ export interface OpenclawDiscoveryConfig {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/** Third-party messaging provider supported by the daemon's channel factory. */
|
|
103
|
-
export type ThirdPartyGatewayType = "telegram" | "wechat";
|
|
103
|
+
export type ThirdPartyGatewayType = "telegram" | "wechat" | "feishu";
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* One third-party gateway profile bound to a BotCord agent. `id` is the
|
|
@@ -122,6 +122,9 @@ export interface ThirdPartyGatewayProfile {
|
|
|
122
122
|
allowedChatIds?: string[];
|
|
123
123
|
splitAt?: number;
|
|
124
124
|
baseUrl?: string;
|
|
125
|
+
appId?: string;
|
|
126
|
+
domain?: "feishu" | "lark";
|
|
127
|
+
userOpenId?: string;
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
export interface DaemonConfig {
|
|
@@ -445,9 +448,9 @@ export function loadConfig(): DaemonConfig {
|
|
|
445
448
|
`daemon config thirdPartyGateways[${i}].id must be a non-empty string (${CONFIG_PATH})`,
|
|
446
449
|
);
|
|
447
450
|
}
|
|
448
|
-
if (gg.type !== "telegram" && gg.type !== "wechat") {
|
|
451
|
+
if (gg.type !== "telegram" && gg.type !== "wechat" && gg.type !== "feishu") {
|
|
449
452
|
throw new Error(
|
|
450
|
-
`daemon config thirdPartyGateways[${i}].type must be "telegram" or "
|
|
453
|
+
`daemon config thirdPartyGateways[${i}].type must be "telegram", "wechat", or "feishu" (${CONFIG_PATH})`,
|
|
451
454
|
);
|
|
452
455
|
}
|
|
453
456
|
if (typeof gg.accountId !== "string" || gg.accountId.length === 0) {
|
package/src/cross-room.ts
CHANGED
|
@@ -44,7 +44,9 @@ export function buildCrossRoomDigest(opts: DigestOptions): string | null {
|
|
|
44
44
|
|
|
45
45
|
const lines: string[] = [
|
|
46
46
|
"[BotCord Cross-Room Awareness]",
|
|
47
|
-
`You are currently active in ${total} BotCord sessions.
|
|
47
|
+
`You are currently active in ${total} BotCord sessions. The entries below are latest messages from OTHER rooms, not the current room.`,
|
|
48
|
+
"Do not treat any sender or message below as the current user or current conversation.",
|
|
49
|
+
"Recent activity from other rooms:",
|
|
48
50
|
];
|
|
49
51
|
for (const e of slice) {
|
|
50
52
|
lines.push(formatEntry(e));
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -262,6 +262,9 @@ export function toGatewayConfig(
|
|
|
262
262
|
if (g.allowedChatIds !== undefined) ch.allowedChatIds = g.allowedChatIds;
|
|
263
263
|
if (g.splitAt !== undefined) ch.splitAt = g.splitAt;
|
|
264
264
|
if (g.baseUrl !== undefined) ch.baseUrl = g.baseUrl;
|
|
265
|
+
if (g.appId !== undefined) ch.appId = g.appId;
|
|
266
|
+
if (g.domain !== undefined) ch.domain = g.domain;
|
|
267
|
+
if (g.userOpenId !== undefined) ch.userOpenId = g.userOpenId;
|
|
265
268
|
channels.push(ch);
|
|
266
269
|
}
|
|
267
270
|
|
package/src/daemon.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
Gateway,
|
|
8
8
|
createBotCordChannel,
|
|
9
|
+
createFeishuChannel,
|
|
9
10
|
createTelegramChannel,
|
|
10
11
|
createWechatChannel,
|
|
11
12
|
resolveTranscriptEnabled,
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
} from "./loop-risk.js";
|
|
45
46
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
46
47
|
import { UserAuthManager } from "./user-auth.js";
|
|
47
|
-
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
48
|
+
import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
|
|
48
49
|
import { scanMention } from "./mention-scan.js";
|
|
49
50
|
import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
|
|
50
51
|
|
|
@@ -178,6 +179,23 @@ export function createDaemonChannel(
|
|
|
178
179
|
...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
|
|
179
180
|
...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
|
|
180
181
|
});
|
|
182
|
+
case "feishu":
|
|
183
|
+
return createFeishuChannel({
|
|
184
|
+
id: chCfg.id,
|
|
185
|
+
accountId: chCfg.accountId,
|
|
186
|
+
...(typeof chCfg.appId === "string" ? { appId: chCfg.appId } : {}),
|
|
187
|
+
...(chCfg.domain === "feishu" || chCfg.domain === "lark"
|
|
188
|
+
? { domain: chCfg.domain }
|
|
189
|
+
: {}),
|
|
190
|
+
...(Array.isArray(chCfg.allowedSenderIds)
|
|
191
|
+
? { allowedSenderIds: chCfg.allowedSenderIds as string[] }
|
|
192
|
+
: {}),
|
|
193
|
+
...(Array.isArray(chCfg.allowedChatIds)
|
|
194
|
+
? { allowedChatIds: chCfg.allowedChatIds as string[] }
|
|
195
|
+
: {}),
|
|
196
|
+
...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
|
|
197
|
+
...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
|
|
198
|
+
});
|
|
181
199
|
default:
|
|
182
200
|
throw new Error(`unknown channel type "${chCfg.type}"`);
|
|
183
201
|
}
|
|
@@ -436,15 +454,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
436
454
|
// with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
|
|
437
455
|
// effective policy, then defer to the protocol-core `shouldWake` decision.
|
|
438
456
|
const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
|
|
439
|
-
const policy:
|
|
457
|
+
const policy: DaemonAttentionPolicy = await policyResolver.resolve(
|
|
440
458
|
msg.accountId,
|
|
441
459
|
msg.conversation.id,
|
|
442
460
|
);
|
|
461
|
+
if (policy.mode === "allowed_senders") {
|
|
462
|
+
return (policy.allowedSenderIds ?? []).includes(msg.sender.id);
|
|
463
|
+
}
|
|
443
464
|
const localMention = scanMention(msg.text, {
|
|
444
465
|
agentId: msg.accountId,
|
|
445
466
|
displayName: displayNameByAgent.get(msg.accountId),
|
|
446
467
|
});
|
|
447
|
-
return shouldWake(policy, {
|
|
468
|
+
return shouldWake(policy as AttentionPolicy, {
|
|
448
469
|
mentioned: msg.mentioned === true || localMention,
|
|
449
470
|
text: msg.text,
|
|
450
471
|
});
|
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();
|
|
@@ -1851,7 +1903,7 @@ describe("Dispatcher", () => {
|
|
|
1851
1903
|
expect(channel.sends.length).toBe(0);
|
|
1852
1904
|
});
|
|
1853
1905
|
|
|
1854
|
-
it("non-owner-chat room: timeout
|
|
1906
|
+
it("non-owner-chat room: timeout sends a diagnostic reply", async () => {
|
|
1855
1907
|
vi.useFakeTimers();
|
|
1856
1908
|
try {
|
|
1857
1909
|
const runtime = new FakeRuntime({ hang: true });
|
|
@@ -1868,13 +1920,16 @@ describe("Dispatcher", () => {
|
|
|
1868
1920
|
await vi.advanceTimersByTimeAsync(501);
|
|
1869
1921
|
await p;
|
|
1870
1922
|
expect(runtime.calls[0].signal.aborted).toBe(true);
|
|
1871
|
-
expect(channel.sends.length).toBe(
|
|
1923
|
+
expect(channel.sends.length).toBe(1);
|
|
1924
|
+
expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
|
|
1925
|
+
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
1926
|
+
expect(channel.sends[0].message.replyTo).toBe("m_to");
|
|
1872
1927
|
} finally {
|
|
1873
1928
|
vi.useRealTimers();
|
|
1874
1929
|
}
|
|
1875
1930
|
});
|
|
1876
1931
|
|
|
1877
|
-
it("non-owner-chat room: runtime error
|
|
1932
|
+
it("non-owner-chat room: runtime error sends a diagnostic reply", async () => {
|
|
1878
1933
|
const runtime = new FakeRuntime({ throwError: "boom" });
|
|
1879
1934
|
const { dispatcher, channel } = await scaffold({
|
|
1880
1935
|
runtimeFactory: () => runtime,
|
|
@@ -1885,7 +1940,10 @@ describe("Dispatcher", () => {
|
|
|
1885
1940
|
conversation: { id: "rm_g_other", kind: "group" },
|
|
1886
1941
|
}),
|
|
1887
1942
|
);
|
|
1888
|
-
expect(channel.sends.length).toBe(
|
|
1943
|
+
expect(channel.sends.length).toBe(1);
|
|
1944
|
+
expect(channel.sends[0].message.text).toContain("Runtime error: boom");
|
|
1945
|
+
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
1946
|
+
expect(channel.sends[0].message.replyTo).toBe("m_err");
|
|
1889
1947
|
});
|
|
1890
1948
|
|
|
1891
1949
|
// ─────────────────────────────────────────────────────────────────────
|