@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.
@@ -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 16200 in default discovery ports", () => {
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<Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["probe"]>(
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("reuses existing auth even when a one-time install token is present", () => {
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("reuse-existing");
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: Record<string, unknown> | null }>,
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
- try {
45
- parsed = JSON.parse(init.body as string) as Record<string, unknown>;
46
- } catch {
47
- parsed = null;
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 { status: 404, ok: false, text: async () => "" };
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
- const clientId = `botcord-${randomUUID()}`;
502
- const body = {
503
- msg: {
504
- from_user_id: "",
505
- to_user_id: trace.fromUserId,
506
- client_id: clientId,
507
- message_type: 2, // BOT → user
508
- message_state: 2, // FINISH
509
- context_token: trace.contextToken,
510
- item_list: [{ type: 1, text_item: { text: chunk } }],
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) {
@@ -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
  }