@botcord/daemon 0.2.51 → 0.2.53
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/gateway/channels/http-types.d.ts +4 -1
- package/dist/gateway/channels/wechat.js +152 -19
- package/dist/gateway/types.d.ts +10 -0
- package/dist/index.js +85 -15
- package/dist/openclaw-discovery.js +33 -2
- package/dist/provision.js +55 -13
- package/dist/start-auth.js +2 -2
- package/dist/turn-text.js +9 -0
- package/package.json +1 -1
- package/src/__tests__/openclaw-discovery.test.ts +37 -2
- package/src/__tests__/provision.test.ts +67 -1
- package/src/__tests__/start-auth.test.ts +2 -2
- package/src/__tests__/turn-text.test.ts +3 -0
- package/src/__tests__/wechat-channel.test.ts +126 -8
- package/src/gateway/channels/http-types.ts +2 -1
- package/src/gateway/channels/wechat.ts +180 -19
- package/src/gateway/types.ts +11 -0
- package/src/index.ts +83 -16
- package/src/openclaw-discovery.ts +33 -2
- package/src/provision.ts +51 -12
- package/src/start-auth.ts +1 -1
- package/src/turn-text.ts +13 -0
|
@@ -128,6 +128,41 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
128
128
|
]);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
it("discovers QClaw's state config and referenced OpenClaw config", async () => {
|
|
132
|
+
const dir = tempDir();
|
|
133
|
+
const openclawConfig = path.join(dir, "openclaw.json");
|
|
134
|
+
writeFileSync(
|
|
135
|
+
openclawConfig,
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
gateway: {
|
|
138
|
+
port: 28789,
|
|
139
|
+
bind: "loopback",
|
|
140
|
+
auth: { mode: "token", token: "qclaw-token" },
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
writeFileSync(
|
|
145
|
+
path.join(dir, "qclaw.json"),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
configPath: openclawConfig,
|
|
148
|
+
port: 28789,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const found = await discoverLocalOpenclawGateways({
|
|
153
|
+
searchPaths: [dir],
|
|
154
|
+
defaultPorts: [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(found).toEqual([
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
url: "ws://127.0.0.1:28789",
|
|
160
|
+
token: "qclaw-token",
|
|
161
|
+
source: "config-file",
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
131
166
|
it("uses OPENCLAW_ACP_URL and token env vars", async () => {
|
|
132
167
|
const found = await discoverLocalOpenclawGateways({
|
|
133
168
|
searchPaths: [],
|
|
@@ -269,8 +304,8 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
269
304
|
]);
|
|
270
305
|
});
|
|
271
306
|
|
|
272
|
-
it("includes
|
|
273
|
-
expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200]));
|
|
307
|
+
it("includes OpenClaw and QClaw ports in default discovery ports", () => {
|
|
308
|
+
expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200, 28789]));
|
|
274
309
|
});
|
|
275
310
|
|
|
276
311
|
it("adds default-port candidates only when the probe succeeds", async () => {
|
|
@@ -27,6 +27,7 @@ vi.mock("../config.js", async () => {
|
|
|
27
27
|
const {
|
|
28
28
|
addAgentToConfig,
|
|
29
29
|
adoptDiscoveredOpenclawAgents,
|
|
30
|
+
probeOpenclawAgents,
|
|
30
31
|
removeAgentFromConfig,
|
|
31
32
|
reloadConfig,
|
|
32
33
|
setRoute,
|
|
@@ -34,6 +35,7 @@ const {
|
|
|
34
35
|
} = await import("../provision.js");
|
|
35
36
|
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
36
37
|
import type { DaemonConfig } from "../config.js";
|
|
38
|
+
import type { WsEndpointProbeFn } from "../provision.js";
|
|
37
39
|
import type {
|
|
38
40
|
GatewayChannelConfig,
|
|
39
41
|
GatewayRoute,
|
|
@@ -1139,7 +1141,7 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
1139
1141
|
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
1140
1142
|
};
|
|
1141
1143
|
const register = vi.fn();
|
|
1142
|
-
const probe = vi.fn<
|
|
1144
|
+
const probe = vi.fn<WsEndpointProbeFn>(
|
|
1143
1145
|
async () => ({ ok: true, agents: [{ id: "main" }] }),
|
|
1144
1146
|
);
|
|
1145
1147
|
|
|
@@ -1224,6 +1226,70 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
1224
1226
|
});
|
|
1225
1227
|
});
|
|
1226
1228
|
|
|
1229
|
+
describe("probeOpenclawAgents local profiles", () => {
|
|
1230
|
+
it("enriches loopback QClaw gateways from ~/.qclaw/openclaw.json", async () => {
|
|
1231
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1232
|
+
const { WebSocketServer } = await import("ws");
|
|
1233
|
+
const qclawDir = nodePath.join(tmp, ".qclaw");
|
|
1234
|
+
fs.mkdirSync(qclawDir, { recursive: true });
|
|
1235
|
+
|
|
1236
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
1237
|
+
await new Promise<void>((resolve) => wss.once("listening", resolve));
|
|
1238
|
+
const address = wss.address();
|
|
1239
|
+
if (typeof address === "string" || address === null) {
|
|
1240
|
+
throw new Error("expected tcp websocket address");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
fs.writeFileSync(
|
|
1244
|
+
nodePath.join(qclawDir, "openclaw.json"),
|
|
1245
|
+
JSON.stringify({
|
|
1246
|
+
agents: {
|
|
1247
|
+
defaults: {
|
|
1248
|
+
workspace: nodePath.join(qclawDir, "workspace"),
|
|
1249
|
+
model: { primary: "qclaw/modelroute" },
|
|
1250
|
+
},
|
|
1251
|
+
list: [{ id: "main", name: "QClaw" }],
|
|
1252
|
+
},
|
|
1253
|
+
gateway: {
|
|
1254
|
+
port: address.port,
|
|
1255
|
+
auth: { mode: "token", token: "qclaw-token" },
|
|
1256
|
+
},
|
|
1257
|
+
}),
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
wss.on("connection", (ws) => {
|
|
1261
|
+
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n" } }));
|
|
1262
|
+
ws.on("message", (raw) => {
|
|
1263
|
+
const msg = JSON.parse(raw.toString("utf8"));
|
|
1264
|
+
if (msg.method === "connect") {
|
|
1265
|
+
ws.send(
|
|
1266
|
+
JSON.stringify({
|
|
1267
|
+
type: "res",
|
|
1268
|
+
id: msg.id,
|
|
1269
|
+
ok: true,
|
|
1270
|
+
payload: { type: "hello-ok", server: { version: "2026.4.21" } },
|
|
1271
|
+
}),
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
try {
|
|
1278
|
+
const res = await probeOpenclawAgents({
|
|
1279
|
+
url: `ws://127.0.0.1:${address.port}`,
|
|
1280
|
+
token: "qclaw-token",
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
expect(res.ok).toBe(true);
|
|
1284
|
+
expect(res.version).toBe("2026.4.21");
|
|
1285
|
+
expect(res.agents).toEqual([{ id: "main", name: "QClaw" }]);
|
|
1286
|
+
} finally {
|
|
1287
|
+
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1227
1293
|
// ---------------------------------------------------------------------------
|
|
1228
1294
|
// revoke_agent — new flag semantics (plan §11.3)
|
|
1229
1295
|
// ---------------------------------------------------------------------------
|
|
@@ -14,14 +14,14 @@ const existingAuth: UserAuthRecord = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
describe("resolveStartAuthAction", () => {
|
|
17
|
-
it("
|
|
17
|
+
it("redeems an install token even when existing auth is available", () => {
|
|
18
18
|
expect(
|
|
19
19
|
resolveStartAuthAction({
|
|
20
20
|
existing: existingAuth,
|
|
21
21
|
relogin: false,
|
|
22
22
|
installToken: "dit_expired",
|
|
23
23
|
}),
|
|
24
|
-
).toBe("
|
|
24
|
+
).toBe("install-token");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("redeems an install token when no existing auth is available", () => {
|
|
@@ -116,6 +116,8 @@ describe("composeBotCordUserTurn", () => {
|
|
|
116
116
|
);
|
|
117
117
|
expect(out).toContain("third-party gateway chat");
|
|
118
118
|
expect(out).toContain("Reply normally in your final assistant message");
|
|
119
|
+
expect(out).toContain("conversation_id: telegram:user:7904063707");
|
|
120
|
+
expect(out).toContain("channel: gw_telegram_123");
|
|
119
121
|
expect(out).not.toContain("Plain text output WILL NOT be sent");
|
|
120
122
|
expect(out).not.toContain("botcord_send");
|
|
121
123
|
});
|
|
@@ -219,6 +221,7 @@ describe("composeBotCordUserTurn", () => {
|
|
|
219
221
|
}),
|
|
220
222
|
);
|
|
221
223
|
expect(out).toContain("[BotCord Messages (2 new)]");
|
|
224
|
+
expect(out).toContain("conversation_id: rm_team");
|
|
222
225
|
expect(out).toContain("room: Ouraca");
|
|
223
226
|
expect(out).toContain("mentioned: true");
|
|
224
227
|
expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -20,6 +20,7 @@ const SILENT_LOG: GatewayLogger = {
|
|
|
20
20
|
interface StubResponse {
|
|
21
21
|
status?: number;
|
|
22
22
|
body: unknown;
|
|
23
|
+
headers?: Record<string, string>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -35,16 +36,20 @@ function buildFetchStub(
|
|
|
35
36
|
body: Record<string, unknown> | null,
|
|
36
37
|
) => StubResponse | Promise<StubResponse>;
|
|
37
38
|
}>,
|
|
38
|
-
calls: Array<{ url: string; body:
|
|
39
|
+
calls: Array<{ url: string; body: any }>,
|
|
39
40
|
): FetchLike {
|
|
40
41
|
const counters = new Map<string, number>();
|
|
41
42
|
return async (url, init) => {
|
|
42
|
-
let parsed: Record<string, unknown> | null = null;
|
|
43
|
+
let parsed: Record<string, unknown> | Uint8Array | null = null;
|
|
43
44
|
if (init?.body) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (typeof init.body === "string") {
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(init.body) as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
parsed = null;
|
|
50
|
+
}
|
|
51
|
+
} else if (init.body instanceof Uint8Array) {
|
|
52
|
+
parsed = init.body;
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
calls.push({ url, body: parsed });
|
|
@@ -59,11 +64,21 @@ function buildFetchStub(
|
|
|
59
64
|
return {
|
|
60
65
|
status,
|
|
61
66
|
ok: status >= 200 && status < 300,
|
|
67
|
+
headers: {
|
|
68
|
+
get(name: string) {
|
|
69
|
+
return resp.headers?.[name] ?? resp.headers?.[name.toLowerCase()] ?? null;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
62
72
|
text: async () => text,
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
status: 404,
|
|
78
|
+
ok: false,
|
|
79
|
+
headers: { get: () => null },
|
|
80
|
+
text: async () => "",
|
|
81
|
+
};
|
|
67
82
|
};
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -409,6 +424,109 @@ describe("wechat channel adapter", () => {
|
|
|
409
424
|
expect(calls.find((c) => c.url.includes("sendmessage"))).toBeUndefined();
|
|
410
425
|
});
|
|
411
426
|
|
|
427
|
+
it("send() uploads local file attachments to WeChat CDN and sends a file_item", async () => {
|
|
428
|
+
const filePath = path.join(tmp, "report.pdf");
|
|
429
|
+
writeFileSync(filePath, "plain file bytes");
|
|
430
|
+
const calls: Array<{ url: string; body: any }> = [];
|
|
431
|
+
const fetchImpl = buildFetchStub(
|
|
432
|
+
[
|
|
433
|
+
{
|
|
434
|
+
match: "getupdates",
|
|
435
|
+
respond: (idx) => {
|
|
436
|
+
if (idx === 0) {
|
|
437
|
+
return {
|
|
438
|
+
body: {
|
|
439
|
+
ret: 0,
|
|
440
|
+
get_updates_buf: "c",
|
|
441
|
+
msgs: [
|
|
442
|
+
{
|
|
443
|
+
message_type: 1,
|
|
444
|
+
from_user_id: "alice@im.wechat",
|
|
445
|
+
context_token: "ctx-file",
|
|
446
|
+
item_list: [{ type: 1, text_item: { text: "send file" } }],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return { body: { ret: 0, get_updates_buf: "c", msgs: [] } };
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
match: "getuploadurl",
|
|
457
|
+
respond: () => ({
|
|
458
|
+
body: {
|
|
459
|
+
ret: 0,
|
|
460
|
+
upload_full_url: "https://cdn.test/upload/report",
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
match: "cdn.test/upload",
|
|
466
|
+
respond: () => ({
|
|
467
|
+
body: "",
|
|
468
|
+
headers: { "x-encrypted-param": "encrypted-download-token" },
|
|
469
|
+
}),
|
|
470
|
+
},
|
|
471
|
+
{ match: "sendmessage", respond: () => ({ body: { ret: 0 } }) },
|
|
472
|
+
],
|
|
473
|
+
calls,
|
|
474
|
+
);
|
|
475
|
+
const adapter = createWechatChannel({
|
|
476
|
+
id: "gw_wx_file",
|
|
477
|
+
accountId: "ag_test",
|
|
478
|
+
botToken: "tok",
|
|
479
|
+
stateFile: path.join(tmp, "state.json"),
|
|
480
|
+
fetchImpl,
|
|
481
|
+
stateDebounceMs: 0,
|
|
482
|
+
allowedSenderIds: ["alice@im.wechat"],
|
|
483
|
+
});
|
|
484
|
+
const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
|
|
485
|
+
await h.pollDone;
|
|
486
|
+
const traceId = h.envelopes[0]!.message.trace!.id;
|
|
487
|
+
|
|
488
|
+
await adapter.send({
|
|
489
|
+
log: SILENT_LOG,
|
|
490
|
+
message: {
|
|
491
|
+
channel: "gw_wx_file",
|
|
492
|
+
accountId: "ag_test",
|
|
493
|
+
conversationId: "wechat:user:alice@im.wechat",
|
|
494
|
+
text: "",
|
|
495
|
+
traceId,
|
|
496
|
+
attachments: [
|
|
497
|
+
{
|
|
498
|
+
filePath,
|
|
499
|
+
filename: "report.pdf",
|
|
500
|
+
contentType: "application/pdf",
|
|
501
|
+
kind: "file",
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const uploadRequest = calls.find((c) => c.url.includes("getuploadurl"))!;
|
|
508
|
+
expect(uploadRequest.body!.media_type).toBe(3);
|
|
509
|
+
expect(uploadRequest.body!.rawsize).toBe("plain file bytes".length);
|
|
510
|
+
expect(uploadRequest.body!.filesize).toBeGreaterThan("plain file bytes".length);
|
|
511
|
+
|
|
512
|
+
const cdnCall = calls.find((c) => c.url.includes("cdn.test/upload"))!;
|
|
513
|
+
expect(cdnCall.body).toBeInstanceOf(Uint8Array);
|
|
514
|
+
expect(Buffer.from(cdnCall.body as Uint8Array).toString("utf8")).not.toContain(
|
|
515
|
+
"plain file bytes",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const sendCall = calls.find((c) => c.url.includes("sendmessage"))!;
|
|
519
|
+
const msg = sendCall.body!.msg as Record<string, unknown>;
|
|
520
|
+
const item = (msg.item_list as Array<Record<string, unknown>>)[0]!;
|
|
521
|
+
expect(item.type).toBe(4);
|
|
522
|
+
const fileItem = item.file_item as Record<string, unknown>;
|
|
523
|
+
expect(fileItem.file_name).toBe("report.pdf");
|
|
524
|
+
expect(fileItem.len).toBe("plain file bytes".length);
|
|
525
|
+
expect((fileItem.media as Record<string, unknown>).encrypt_query_param).toBe(
|
|
526
|
+
"encrypted-download-token",
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
412
530
|
it("send() splits long replies into chunks <= splitAt, preferring newline boundaries", async () => {
|
|
413
531
|
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
414
532
|
const fetchImpl = buildFetchStub(
|
|
@@ -12,11 +12,12 @@ export type FetchLike = (
|
|
|
12
12
|
init?: {
|
|
13
13
|
method?: string;
|
|
14
14
|
headers?: Record<string, string>;
|
|
15
|
-
body?: string;
|
|
15
|
+
body?: BodyInit | Uint8Array | string;
|
|
16
16
|
signal?: AbortSignal;
|
|
17
17
|
},
|
|
18
18
|
) => Promise<{
|
|
19
19
|
status?: number;
|
|
20
20
|
ok?: boolean;
|
|
21
|
+
headers?: { get(name: string): string | null };
|
|
21
22
|
text(): Promise<string>;
|
|
22
23
|
}>;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createCipheriv,
|
|
5
|
+
createHash,
|
|
6
|
+
randomBytes,
|
|
7
|
+
randomUUID,
|
|
8
|
+
} from "node:crypto";
|
|
1
9
|
import type {
|
|
2
10
|
ChannelAdapter,
|
|
3
11
|
ChannelSendContext,
|
|
@@ -8,15 +16,16 @@ import type {
|
|
|
8
16
|
ChannelTypingContext,
|
|
9
17
|
GatewayInboundEnvelope,
|
|
10
18
|
GatewayInboundMessage,
|
|
19
|
+
GatewayOutboundAttachment,
|
|
11
20
|
} from "../types.js";
|
|
12
21
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
13
22
|
import { GatewayStateStore } from "./state-store.js";
|
|
14
23
|
import { loadGatewaySecret } from "./secret-store.js";
|
|
15
24
|
import { splitText } from "./text-split.js";
|
|
16
25
|
import { wechatHeaders, WECHAT_BASE_INFO, type FetchLike } from "./wechat-http.js";
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
18
26
|
|
|
19
27
|
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
28
|
+
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
|
|
@@ -92,6 +101,11 @@ interface WechatGenericResp {
|
|
|
92
101
|
[k: string]: unknown;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
interface WechatUploadUrlResp extends WechatGenericResp {
|
|
105
|
+
upload_param?: string;
|
|
106
|
+
upload_full_url?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
interface TraceContext {
|
|
96
110
|
contextToken: string;
|
|
97
111
|
fromUserId: string;
|
|
@@ -231,6 +245,142 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
231
245
|
}
|
|
232
246
|
}
|
|
233
247
|
|
|
248
|
+
function cdnUploadUrl(resp: WechatUploadUrlResp): string | null {
|
|
249
|
+
if (typeof resp.upload_full_url === "string" && resp.upload_full_url.length > 0) {
|
|
250
|
+
return resp.upload_full_url;
|
|
251
|
+
}
|
|
252
|
+
if (typeof resp.upload_param === "string" && resp.upload_param.length > 0) {
|
|
253
|
+
return `${DEFAULT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(
|
|
254
|
+
resp.upload_param,
|
|
255
|
+
)}`;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function uploadEncryptedMedia(
|
|
261
|
+
trace: TraceContext,
|
|
262
|
+
attachment: GatewayOutboundAttachment,
|
|
263
|
+
): Promise<Record<string, unknown>> {
|
|
264
|
+
const raw =
|
|
265
|
+
attachment.data ??
|
|
266
|
+
(attachment.filePath ? await readFile(attachment.filePath) : undefined);
|
|
267
|
+
if (!raw || raw.length === 0) {
|
|
268
|
+
throw new Error("wechat media upload requires non-empty attachment data or filePath");
|
|
269
|
+
}
|
|
270
|
+
const data = Buffer.from(raw);
|
|
271
|
+
const filename =
|
|
272
|
+
attachment.filename ??
|
|
273
|
+
(attachment.filePath ? basename(attachment.filePath) : "attachment");
|
|
274
|
+
const kind = attachment.kind ?? kindFromContentType(attachment.contentType);
|
|
275
|
+
const mediaType = kind === "image" ? 1 : kind === "video" ? 2 : 3;
|
|
276
|
+
const itemType = kind === "image" ? 2 : kind === "video" ? 5 : 4;
|
|
277
|
+
const aesKey = randomBytes(16);
|
|
278
|
+
const aesKeyHex = aesKey.toString("hex");
|
|
279
|
+
const encrypted = encryptAes128Ecb(data, aesKey);
|
|
280
|
+
const filekey = `botcord-${randomUUID()}`;
|
|
281
|
+
const uploadResp = await callApi<WechatUploadUrlResp>(
|
|
282
|
+
"ilink/bot/getuploadurl",
|
|
283
|
+
{
|
|
284
|
+
filekey,
|
|
285
|
+
media_type: mediaType,
|
|
286
|
+
to_user_id: trace.fromUserId,
|
|
287
|
+
rawsize: data.length,
|
|
288
|
+
rawfilemd5: md5Hex(data),
|
|
289
|
+
filesize: encrypted.length,
|
|
290
|
+
aeskey: aesKeyHex,
|
|
291
|
+
no_need_thumb: true,
|
|
292
|
+
},
|
|
293
|
+
15_000,
|
|
294
|
+
);
|
|
295
|
+
if (uploadResp.ret !== 0 && uploadResp.ret !== undefined) {
|
|
296
|
+
throw new Error(redactSecret(`wechat getuploadurl failed: ret=${uploadResp.ret}`, botToken));
|
|
297
|
+
}
|
|
298
|
+
const uploadUrl = cdnUploadUrl(uploadResp);
|
|
299
|
+
if (!uploadUrl) throw new Error("wechat getuploadurl returned no upload URL");
|
|
300
|
+
|
|
301
|
+
const uploadResult = await fetchImpl(uploadUrl, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
304
|
+
body: encrypted,
|
|
305
|
+
signal: AbortSignal.timeout(30_000),
|
|
306
|
+
});
|
|
307
|
+
const encryptedParam =
|
|
308
|
+
uploadResult.headers?.get("x-encrypted-param") ??
|
|
309
|
+
uploadResult.headers?.get("X-Encrypted-Param") ??
|
|
310
|
+
(await readEncryptedParamFromBody(uploadResult));
|
|
311
|
+
if (!encryptedParam) {
|
|
312
|
+
throw new Error("wechat CDN upload returned no x-encrypted-param");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const media = {
|
|
316
|
+
encrypt_query_param: encryptedParam,
|
|
317
|
+
aes_key: Buffer.from(aesKeyHex, "utf8").toString("base64"),
|
|
318
|
+
};
|
|
319
|
+
if (itemType === 2) {
|
|
320
|
+
return {
|
|
321
|
+
type: itemType,
|
|
322
|
+
image_item: {
|
|
323
|
+
media,
|
|
324
|
+
aeskey: aesKeyHex,
|
|
325
|
+
mid_size: data.length,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (itemType === 5) {
|
|
330
|
+
return {
|
|
331
|
+
type: itemType,
|
|
332
|
+
video_item: {
|
|
333
|
+
media,
|
|
334
|
+
video_size: data.length,
|
|
335
|
+
file_name: filename,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
type: itemType,
|
|
341
|
+
file_item: {
|
|
342
|
+
media,
|
|
343
|
+
file_name: filename,
|
|
344
|
+
md5: md5Hex(data),
|
|
345
|
+
len: data.length,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readEncryptedParamFromBody(
|
|
351
|
+
resp: Awaited<ReturnType<FetchLike>>,
|
|
352
|
+
): Promise<string | null> {
|
|
353
|
+
const raw = await resp.text().catch(() => "");
|
|
354
|
+
if (!raw) return null;
|
|
355
|
+
try {
|
|
356
|
+
const json = JSON.parse(raw) as Record<string, unknown>;
|
|
357
|
+
const v = json.encrypted_query_param ?? json.encrypt_query_param ?? json.upload_param;
|
|
358
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function sendItems(trace: TraceContext, items: Record<string, unknown>[]): Promise<string> {
|
|
365
|
+
const clientId = `botcord-${randomUUID()}`;
|
|
366
|
+
const body = {
|
|
367
|
+
msg: {
|
|
368
|
+
from_user_id: "",
|
|
369
|
+
to_user_id: trace.fromUserId,
|
|
370
|
+
client_id: clientId,
|
|
371
|
+
message_type: 2, // BOT → user
|
|
372
|
+
message_state: 2, // FINISH
|
|
373
|
+
context_token: trace.contextToken,
|
|
374
|
+
item_list: items,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const resp = await callApi<WechatGenericResp>("ilink/bot/sendmessage", body, 15_000);
|
|
378
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
379
|
+
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
380
|
+
}
|
|
381
|
+
return clientId;
|
|
382
|
+
}
|
|
383
|
+
|
|
234
384
|
function extractText(msg: WechatInboundMsg): string {
|
|
235
385
|
const parts: string[] = [];
|
|
236
386
|
for (const item of msg.item_list ?? []) {
|
|
@@ -495,27 +645,22 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
495
645
|
);
|
|
496
646
|
}
|
|
497
647
|
|
|
498
|
-
const chunks = splitText(message.text, splitAt);
|
|
648
|
+
const chunks = message.text.length > 0 ? splitText(message.text, splitAt) : [];
|
|
499
649
|
let lastClientId: string | null = null;
|
|
500
650
|
for (const chunk of chunks) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const resp = await callApi<WechatGenericResp>("ilink/bot/sendmessage", body, 15_000);
|
|
514
|
-
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
515
|
-
log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
|
|
516
|
-
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
651
|
+
lastClientId = await sendItems(trace, [{ type: 1, text_item: { text: chunk } }]);
|
|
652
|
+
}
|
|
653
|
+
for (const attachment of message.attachments ?? []) {
|
|
654
|
+
try {
|
|
655
|
+
const item = await uploadEncryptedMedia(trace, attachment);
|
|
656
|
+
lastClientId = await sendItems(trace, [item]);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
log.warn("wechat media send failed", {
|
|
659
|
+
err: redactSecret(String(err), botToken),
|
|
660
|
+
filename: attachment.filename ?? attachment.filePath ?? "attachment",
|
|
661
|
+
});
|
|
662
|
+
throw err;
|
|
517
663
|
}
|
|
518
|
-
lastClientId = clientId;
|
|
519
664
|
}
|
|
520
665
|
const sendAt = Date.now();
|
|
521
666
|
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
@@ -553,6 +698,22 @@ export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter
|
|
|
553
698
|
return adapter;
|
|
554
699
|
}
|
|
555
700
|
|
|
701
|
+
function md5Hex(data: Buffer): string {
|
|
702
|
+
return createHash("md5").update(data).digest("hex");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function encryptAes128Ecb(data: Buffer, key: Buffer): Buffer {
|
|
706
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
707
|
+
cipher.setAutoPadding(true);
|
|
708
|
+
return Buffer.concat([cipher.update(data), cipher.final()]);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function kindFromContentType(contentType: string | undefined): "image" | "file" | "video" {
|
|
712
|
+
if (contentType?.startsWith("image/")) return "image";
|
|
713
|
+
if (contentType?.startsWith("video/")) return "video";
|
|
714
|
+
return "file";
|
|
715
|
+
}
|
|
716
|
+
|
|
556
717
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
557
718
|
return new Promise((resolve) => {
|
|
558
719
|
if (signal?.aborted) {
|
package/src/gateway/types.ts
CHANGED
|
@@ -172,12 +172,23 @@ export type OutboundObserver = (
|
|
|
172
172
|
) => Promise<void> | void;
|
|
173
173
|
|
|
174
174
|
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
175
|
+
export interface GatewayOutboundAttachment {
|
|
176
|
+
/** Local daemon-readable file path. */
|
|
177
|
+
filePath?: string;
|
|
178
|
+
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
179
|
+
data?: Uint8Array;
|
|
180
|
+
filename?: string;
|
|
181
|
+
contentType?: string;
|
|
182
|
+
kind?: "image" | "file" | "video";
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
export interface GatewayOutboundMessage {
|
|
176
186
|
channel: string;
|
|
177
187
|
accountId: string;
|
|
178
188
|
conversationId: string;
|
|
179
189
|
threadId?: string | null;
|
|
180
190
|
text: string;
|
|
191
|
+
attachments?: GatewayOutboundAttachment[];
|
|
181
192
|
replyTo?: string | null;
|
|
182
193
|
traceId?: string | null;
|
|
183
194
|
}
|