@agent-wechat/agent-wechat 1.0.0-beta.10 → 1.0.0-beta.12

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/index.js CHANGED
@@ -114,13 +114,26 @@ import { delimiter, dirname, isAbsolute, join, toNamespacedPath } from "node:pat
114
114
  import { createConnection } from "node:net";
115
115
 
116
116
  // ../shared/dist/native.generated.js
117
- var NATIVE_MAX_SOURCE_BYTES = 26214383;
117
+ var NATIVE_MAX_SOURCE_BYTES = 536870912;
118
+ var NATIVE_MAX_ORDINARY_SOURCE_BYTES = 26214383;
119
+ var NATIVE_MAX_VIDEO_SOURCE_BYTES = 52428800;
118
120
 
119
121
  // ../shared/dist/native-client.js
120
122
  function isNativeTombstone(value) {
121
123
  const item = value;
122
124
  return item?.deleted === true && typeof item.id === "string" && typeof item.version === "string";
123
125
  }
126
+ function validateNativeRecallMetadata(value) {
127
+ if (!value || typeof value !== "object" || Array.isArray(value))
128
+ throw new Error("Invalid native recall metadata");
129
+ const recall = value;
130
+ if (recall.replacementText != null && (typeof recall.replacementText !== "string" || !recall.replacementText || recall.replacementText.length > 16384 || new TextEncoder().encode(recall.replacementText).length > 16384 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\ufffe\uffff]/.test(recall.replacementText)))
131
+ throw new Error("Invalid native recall metadata");
132
+ }
133
+ function validateMessageRecall(message) {
134
+ if (message.recall != null)
135
+ validateNativeRecallMetadata(message.recall);
136
+ }
124
137
  function listQuery(input) {
125
138
  const options = typeof input === "string" ? { cursor: input } : input;
126
139
  if (!options)
@@ -169,6 +182,7 @@ var NativeWeChatClient = class {
169
182
  token;
170
183
  fetchImpl;
171
184
  timeoutMs;
185
+ mediaTimeoutMs;
172
186
  streamIdleTimeoutMs;
173
187
  constructor(options) {
174
188
  this.baseUrl = validateNativeBaseUrl(options.baseUrl);
@@ -180,16 +194,19 @@ var NativeWeChatClient = class {
180
194
  this.token = options.token;
181
195
  this.fetchImpl = options.fetch ?? fetch;
182
196
  this.timeoutMs = options.timeoutMs ?? 3e4;
197
+ this.mediaTimeoutMs = options.mediaTimeoutMs ?? 60 * 6e4;
198
+ if (!Number.isInteger(this.mediaTimeoutMs) || this.mediaTimeoutMs < 1 || this.mediaTimeoutMs > 60 * 6e4)
199
+ throw new Error("Invalid native media timeout");
183
200
  this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 45e3;
184
201
  if (!Number.isInteger(this.streamIdleTimeoutMs) || this.streamIdleTimeoutMs < 1 || this.streamIdleTimeoutMs > 3e5)
185
202
  throw new Error("Invalid native stream idle timeout");
186
203
  }
187
- async response(path, init = {}, stream = false) {
204
+ async response(path, init = {}, stream = false, timeoutMs = this.timeoutMs) {
188
205
  const headers = new Headers(init.headers);
189
206
  headers.set("Authorization", `Bearer ${this.token}`);
190
207
  if (init.body != null && !(init.body instanceof FormData))
191
208
  headers.set("Content-Type", "application/json");
192
- const timeout = stream ? void 0 : AbortSignal.timeout(this.timeoutMs);
209
+ const timeout = stream ? void 0 : AbortSignal.timeout(timeoutMs);
193
210
  const signal = init.signal && timeout ? AbortSignal.any([init.signal, timeout]) : init.signal ?? timeout;
194
211
  const response = await this.fetchImpl(new URL(path, this.baseUrl), { ...init, headers, redirect: "error", signal });
195
212
  if (!response.ok) {
@@ -344,11 +361,51 @@ var NativeWeChatClient = class {
344
361
  contact(accountId, username) {
345
362
  return this.request(`v1/accounts/${id(accountId)}/contacts/${id(username)}`);
346
363
  }
347
- group(accountId, roomId) {
348
- return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}`);
364
+ group(accountId, roomId, signal) {
365
+ return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}`, { signal });
366
+ }
367
+ groupMembers(accountId, roomId, options, signal) {
368
+ return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}/members${listQuery(options)}`, { signal });
369
+ }
370
+ groupMember(accountId, roomId, username, signal) {
371
+ return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}/members/${id(username)}`, { signal });
372
+ }
373
+ async groupMutation(accountId, path, method, body, key, action, target, signal) {
374
+ if (typeof key !== "string" || !key || key.length > 200 || /[\u0000-\u001f\u007f]/.test(key))
375
+ throw new Error("A stable group idempotency key is required");
376
+ const operation = await this.request(`v1/accounts/${id(accountId)}/${path}`, { method, body: JSON.stringify(body), headers: { "Idempotency-Key": key }, signal });
377
+ const result = operation?.result;
378
+ if (!operation || operation.accountId !== accountId || operation.kind !== "group_mutation" || typeof operation.id !== "string" || !operation.id || !result || result.action !== action || result.targetId !== target)
379
+ throw new Error("Native group operation identity is invalid");
380
+ return operation;
381
+ }
382
+ /** A repeated key returns the same operation. Uncertain mutations must be inspected, never repeated with a new key. */
383
+ createGroup(accountId, input, key, signal) {
384
+ return this.groupMutation(accountId, "groups", "POST", input, key, "create", null, signal);
385
+ }
386
+ renameGroup(accountId, groupId, name3, key, signal) {
387
+ return this.groupMutation(accountId, `groups/${id(groupId)}`, "PATCH", { name: name3 }, key, "rename", groupId, signal);
388
+ }
389
+ leaveGroup(accountId, groupId, key, signal) {
390
+ return this.groupMutation(accountId, `groups/${id(groupId)}/leave`, "POST", {}, key, "leave", groupId, signal);
391
+ }
392
+ addGroupMembers(accountId, groupId, members, key, signal) {
393
+ return this.groupMutation(accountId, `groups/${id(groupId)}/members`, "POST", { members }, key, "add_members", groupId, signal);
394
+ }
395
+ removeGroupMembers(accountId, groupId, members, key, signal) {
396
+ return this.groupMutation(accountId, `groups/${id(groupId)}/members`, "DELETE", { members }, key, "remove_members", groupId, signal);
397
+ }
398
+ setMyGroupName(accountId, groupId, displayName, key, signal) {
399
+ return this.groupMutation(accountId, `groups/${id(groupId)}/my-name`, "PUT", { displayName }, key, "my_name", groupId, signal);
400
+ }
401
+ clearGroupAnnouncement(accountId, groupId, key, signal) {
402
+ return this.groupMutation(accountId, `groups/${id(groupId)}/announcement`, "DELETE", {}, key, "clear_announcement", groupId, signal);
349
403
  }
350
- groupMembers(accountId, roomId, options) {
351
- return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}/members${listQuery(options)}`);
404
+ setThreadMuted(accountId, threadId2, muted, key, signal) {
405
+ return this.groupMutation(accountId, `threads/${id(threadId2)}/notifications`, "PUT", { muted }, key, "mute", threadId2, signal);
406
+ }
407
+ groupMutationByKey(accountId, key, signal) {
408
+ return this.request(`v1/accounts/${id(accountId)}/group-mutations/by-idempotency-key?${new URLSearchParams({ key })}`, { signal });
352
409
  }
353
410
  refreshAvatar(accountId, username) {
354
411
  return this.request(`v1/accounts/${id(accountId)}/avatars/${id(username)}/refresh`, { method: "POST", body: "{}" });
@@ -384,6 +441,7 @@ var NativeWeChatClient = class {
384
441
  continue;
385
442
  if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId2 || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
386
443
  throw new Error("Invalid native message identity");
444
+ validateMessageRecall(msg);
387
445
  }
388
446
  return result;
389
447
  }
@@ -391,13 +449,16 @@ var NativeWeChatClient = class {
391
449
  const message = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}`);
392
450
  if (message.id !== messageId || message.accountId !== accountId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
393
451
  throw new Error("Invalid native message identity");
452
+ validateMessageRecall(message);
394
453
  return message;
395
454
  }
396
455
  /** Resolve only the stored original in this message's own conversation. */
397
456
  async quotedMessage(accountId, messageId) {
398
457
  const quote = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/quote`);
399
- if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
458
+ if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous", "recalled"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
400
459
  throw new Error("Invalid native quote identity");
460
+ if (quote.message)
461
+ validateMessageRecall(quote.message);
401
462
  return quote;
402
463
  }
403
464
  /** Account-wide local search, with the daemon's query-bound opaque cursor. */
@@ -408,13 +469,20 @@ var NativeWeChatClient = class {
408
469
  continue;
409
470
  if (typeof message.id !== "string" || message.accountId !== accountId || typeof message.threadId !== "string" || !message.threadId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
410
471
  throw new Error("Invalid native archive message identity");
472
+ validateMessageRecall(message);
411
473
  }
412
474
  return result;
413
475
  }
414
- sendText(accountId, threadId2, text3, idempotencyKey) {
476
+ /** Quote a locally stored text, image, voice, video, link, file or quoted reply in the same chat.
477
+ * Live acceptance and recipient rendering remain unverified; other source kinds are rejected.
478
+ */
479
+ sendText(accountId, threadId2, text3, idempotencyKey, quote) {
415
480
  if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
416
481
  throw new Error("An idempotency key is required");
417
- return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text: text3 }) });
482
+ if (quote !== void 0 && (!quote || typeof quote !== "object" || Array.isArray(quote) || Object.keys(quote).some((key) => key !== "messageId" && key !== "serverId") || typeof quote.messageId !== "string" || !quote.messageId || quote.messageId.length > 8192 || typeof quote.serverId !== "string" || !/^[1-9]\d{0,19}$/.test(quote.serverId) || BigInt(quote.serverId) > 18446744073709551615n))
483
+ throw new Error("Invalid quote message identity");
484
+ const input = quote === void 0 ? { threadId: threadId2, text: text3 } : { kind: "text", threadId: threadId2, text: text3, quote };
485
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify(input) });
418
486
  }
419
487
  send(accountId, sendId, signal) {
420
488
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
@@ -432,12 +500,16 @@ var NativeWeChatClient = class {
432
500
  cancelSend(accountId, sendId) {
433
501
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
434
502
  }
435
- async upload(accountId, bytes, name2, contentType = "application/octet-stream", signal) {
436
- if (!bytes.length || bytes.length > NATIVE_MAX_SOURCE_BYTES || !name2 || name2.length > 255 || /[\r\n/\\]/.test(name2))
503
+ /** A disk-backed Blob avoids holding the source in memory before staging. */
504
+ async upload(accountId, bytes, name3, contentType = "application/octet-stream", signal) {
505
+ const size = bytes instanceof Blob ? bytes.size : bytes.byteLength;
506
+ if (!size || size > NATIVE_MAX_SOURCE_BYTES || !name3 || name3.length > 255 || /[\r\n/\\]/.test(name3))
437
507
  throw new Error("Invalid bounded media upload");
508
+ const part = bytes instanceof Blob ? bytes.slice(0, bytes.size, contentType) : new Blob([bytes.buffer instanceof ArrayBuffer ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : new Uint8Array(bytes)], { type: contentType });
438
509
  const form = new FormData();
439
- form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }), name2);
440
- return this.request(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal });
510
+ form.set("file", part, name3);
511
+ const response = await this.response(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal }, false, this.mediaTimeoutMs);
512
+ return JSON.parse(new TextDecoder().decode(await this.readBounded(response)));
441
513
  }
442
514
  sendMedia(accountId, input, key) {
443
515
  if (!key || /[\r\n]/.test(key))
@@ -459,26 +531,94 @@ var NativeWeChatClient = class {
459
531
  cancelJob(accountId, jobId) {
460
532
  return this.request(`v1/accounts/${id(accountId)}/jobs/${id(jobId)}/cancel`, { method: "POST", body: "{}" });
461
533
  }
462
- retryJob(accountId, jobId) {
463
- return this.request(`v1/accounts/${id(accountId)}/jobs/${id(jobId)}/retry`, { method: "POST", body: "{}" });
534
+ retryJob(accountId, jobId, options = {}) {
535
+ if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => key !== "reuseCompletedUpload"))
536
+ throw new Error("Invalid media retry options");
537
+ const reuse = options.reuseCompletedUpload;
538
+ if (reuse !== void 0 && typeof reuse !== "boolean")
539
+ throw new Error("Invalid media retry options");
540
+ return this.request(`v1/accounts/${id(accountId)}/jobs/${id(jobId)}/${reuse === true ? "retry-completed-upload" : "retry"}`, { method: "POST", body: JSON.stringify(reuse === void 0 ? {} : { reuseCompletedUpload: reuse }) });
464
541
  }
465
542
  repairMediaWriteback(accountId, limit = 25) {
466
543
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
467
544
  throw new Error("Invalid media writeback limit");
468
545
  return this.request(`v1/accounts/${id(accountId)}/media-writeback/repair`, { method: "POST", body: JSON.stringify({ limit }) });
469
546
  }
470
- async mediaContent(accountId, assetId, options = {}) {
547
+ /** Consume the bounded stream once, or cancel it; this never starts a CDN transfer. */
548
+ async mediaContentStream(accountId, assetId, options = {}) {
471
549
  const max = options.maxBytes ?? 128 * 1024 * 1024;
472
- if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range) || options.representation != null && !["original", "preview"].includes(options.representation))
550
+ if (!Number.isInteger(max) || max < 1 || max > Math.max(128 * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES) || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range) || options.representation != null && !["original", "preview"].includes(options.representation))
473
551
  throw new Error("Invalid bounded content request");
474
552
  const query = options.representation === "preview" ? "?representation=preview" : "";
475
- const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content${query}`, { signal: options.signal, headers: options.range ? { Range: options.range } : {} });
553
+ const cancellation = new AbortController();
554
+ const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content${query}`, { signal: options.signal ? AbortSignal.any([options.signal, cancellation.signal]) : cancellation.signal, headers: options.range ? { Range: options.range } : {} }, false, this.mediaTimeoutMs);
476
555
  const length = response.headers.get("content-length");
477
556
  if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
478
557
  await response.body?.cancel();
479
558
  throw new Error("Native media response exceeds its bound");
480
559
  }
481
- return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), sourceSha256: response.headers.get("x-source-sha256"), status: response.status };
560
+ if (!response.body && length && Number(length) > 0)
561
+ throw new Error("Native media response length mismatch");
562
+ let activeReader;
563
+ const iterate = async function* () {
564
+ cancellation.signal.throwIfAborted();
565
+ options.signal?.throwIfAborted();
566
+ const reader = response.body?.getReader();
567
+ if (!reader)
568
+ return;
569
+ activeReader = reader;
570
+ let size = 0;
571
+ try {
572
+ while (true) {
573
+ cancellation.signal.throwIfAborted();
574
+ options.signal?.throwIfAborted();
575
+ const { value, done } = await reader.read();
576
+ cancellation.signal.throwIfAborted();
577
+ options.signal?.throwIfAborted();
578
+ if (done)
579
+ break;
580
+ size += value.byteLength;
581
+ if (size > max)
582
+ throw new Error("Native media response exceeds its bound");
583
+ yield value;
584
+ }
585
+ if (length && size !== Number(length))
586
+ throw new Error("Native media response length mismatch");
587
+ } finally {
588
+ await reader.cancel().catch(() => {
589
+ });
590
+ reader.releaseLock();
591
+ activeReader = void 0;
592
+ }
593
+ };
594
+ let claimed = false;
595
+ const stream = { [Symbol.asyncIterator]() {
596
+ if (claimed)
597
+ throw new Error("Native media stream was already consumed");
598
+ claimed = true;
599
+ return iterate();
600
+ } };
601
+ return { stream, cancel: async () => {
602
+ cancellation.abort();
603
+ await (activeReader ? activeReader.cancel() : response.body?.cancel())?.catch(() => {
604
+ });
605
+ }, contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), sourceSha256: response.headers.get("x-source-sha256"), status: response.status };
606
+ }
607
+ async mediaContent(accountId, assetId, options = {}) {
608
+ const { stream, cancel: _cancel, ...metadata } = await this.mediaContentStream(accountId, assetId, options);
609
+ const chunks = [];
610
+ let size = 0;
611
+ for await (const chunk of stream) {
612
+ chunks.push(chunk);
613
+ size += chunk.byteLength;
614
+ }
615
+ const bytes = new Uint8Array(size);
616
+ let offset = 0;
617
+ for (const chunk of chunks) {
618
+ bytes.set(chunk, offset);
619
+ offset += chunk.byteLength;
620
+ }
621
+ return { ...metadata, bytes };
482
622
  }
483
623
  markRead(accountId, threadId2, expectedVersion) {
484
624
  if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
@@ -488,7 +628,12 @@ var NativeWeChatClient = class {
488
628
  events(accountId, after = "0", limit = 100) {
489
629
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
490
630
  throw new Error("Invalid event page limit");
491
- return this.request(`v1/accounts/${id(accountId)}/events?after=${decimal(after)}&limit=${limit}`);
631
+ return this.request(`v1/accounts/${id(accountId)}/events?after=${decimal(after)}&limit=${limit}`).then((page) => {
632
+ for (const event of page.items)
633
+ if (event.kind === "message.recalled")
634
+ validateNativeRecallMetadata(event.data);
635
+ return page;
636
+ });
492
637
  }
493
638
  ack(accountId, consumerId, cursor) {
494
639
  return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
@@ -552,6 +697,8 @@ var NativeWeChatClient = class {
552
697
  const event = payload;
553
698
  if (!event || typeof event.kind !== "string" || !event.data || typeof event.data !== "object")
554
699
  throw new Error("Invalid native event");
700
+ if (event.kind === "message.recalled")
701
+ validateNativeRecallMetadata(event.data);
555
702
  event.cursor = decimal(event.cursor ?? eventCursor);
556
703
  yield event;
557
704
  }
@@ -598,7 +745,7 @@ function requireNativeIdentity(value) {
598
745
  }
599
746
  }
600
747
  function nativeChildEnvironment(token, environment = process.env) {
601
- return Object.fromEntries(["PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "WINDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR"].filter((name2) => environment[name2] !== void 0 && environment[name2] !== token).map((name2) => [name2, environment[name2]]));
748
+ return Object.fromEntries(["PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "WINDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR"].filter((name3) => environment[name3] !== void 0 && environment[name3] !== token).map((name3) => [name3, environment[name3]]));
602
749
  }
603
750
  function nativeHostTarget(platform = process.platform, arch = process.arch) {
604
751
  const cpu = arch === "arm64" ? "aarch64" : "x86_64";
@@ -1034,15 +1181,21 @@ function resolveWeChatIngress(params) {
1034
1181
  });
1035
1182
  }
1036
1183
 
1184
+ // src/outbound-options.ts
1185
+ import { createHash as createHash6 } from "node:crypto";
1186
+
1037
1187
  // src/outbound-media.ts
1038
1188
  import { createHash as createHash5 } from "node:crypto";
1039
1189
  import { randomUUID as randomUUID2 } from "node:crypto";
1040
1190
 
1041
1191
  // src/native-media.ts
1042
1192
  import { createHash as createHash4, randomUUID } from "node:crypto";
1043
- import { open } from "node:fs/promises";
1044
- import { basename, extname } from "node:path";
1193
+ import { openAsBlob } from "node:fs";
1194
+ import { mkdtemp, open, rm } from "node:fs/promises";
1195
+ import { tmpdir } from "node:os";
1196
+ import { basename, extname, join as join2 } from "node:path";
1045
1197
  import { getImageMetadata, resizeToJpeg, IMAGE_REDUCE_QUALITY_STEPS } from "openclaw/plugin-sdk/media-runtime";
1198
+ import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
1046
1199
 
1047
1200
  // src/attachment-store.ts
1048
1201
  import { createHash as createHash3 } from "node:crypto";
@@ -1062,6 +1215,12 @@ async function saveManagedAttachment(params) {
1062
1215
  }
1063
1216
  const buffer = Buffer.from(params.data, "base64");
1064
1217
  if (buffer.byteLength !== size) throw new Error("Attachment decoding length mismatch");
1218
+ return saveManagedAttachmentBytes({ ...params, bytes: buffer });
1219
+ }
1220
+ async function saveManagedAttachmentBytes(params) {
1221
+ const size = params.bytes.byteLength;
1222
+ if (size > params.maxBytes) throw new AttachmentTooLargeError(`Attachment exceeds configured ${params.maxBytes} byte limit`);
1223
+ const buffer = Buffer.from(params.bytes.buffer, params.bytes.byteOffset, size);
1065
1224
  const sha256 = createHash3("sha256").update(buffer).digest("hex");
1066
1225
  const saved = await params.saveMediaBuffer(
1067
1226
  buffer,
@@ -1072,31 +1231,59 @@ async function saveManagedAttachment(params) {
1072
1231
  );
1073
1232
  return { path: saved.path, size, sha256, filename: params.filename };
1074
1233
  }
1234
+ async function saveVerifiedManagedAttachmentStream(params) {
1235
+ if (!Number.isSafeInteger(params.expectedBytes) || params.expectedBytes < 1 || params.expectedBytes > params.maxBytes || !/^[a-f0-9]{64}$/.test(params.expectedSha256)) throw new Error("Native attachment integrity metadata is invalid");
1236
+ let size = 0, finished = false;
1237
+ const digest2 = createHash3("sha256");
1238
+ const stream = (async function* () {
1239
+ for await (const chunk of params.stream) {
1240
+ params.assertCurrent();
1241
+ size += chunk.byteLength;
1242
+ if (size > params.maxBytes || size > params.expectedBytes) throw new AttachmentTooLargeError("Native attachment stream exceeds its bound");
1243
+ digest2.update(chunk);
1244
+ yield chunk;
1245
+ }
1246
+ params.assertCurrent();
1247
+ if (size !== params.expectedBytes || digest2.digest("hex") !== params.expectedSha256) throw new Error("Native attachment integrity did not verify");
1248
+ finished = true;
1249
+ })();
1250
+ const saved = await params.saveMediaStream(
1251
+ stream,
1252
+ params.mime,
1253
+ "inbound",
1254
+ params.maxBytes,
1255
+ params.filename,
1256
+ void 0,
1257
+ { assertCommitAllowed: params.assertCurrent, durable: true }
1258
+ );
1259
+ if (!finished || saved.size != null && saved.size !== size) throw new Error("Native attachment stream was not fully saved");
1260
+ return { path: saved.path, size, sha256: params.expectedSha256, filename: params.filename };
1261
+ }
1075
1262
 
1076
1263
  // src/media-delivery.ts
1077
1264
  function appendReference(body, reference) {
1078
1265
  return body ? `${body}
1079
1266
  ${reference}` : reference;
1080
1267
  }
1081
- function safeDisplayName(name2) {
1082
- return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1268
+ function safeDisplayName(name3) {
1269
+ return name3.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1083
1270
  }
1084
1271
  function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
1085
1272
  if (!attachment) return originalBody;
1086
- const name2 = safeDisplayName(attachment.filename);
1273
+ const name3 = safeDisplayName(attachment.filename);
1087
1274
  if (attachment.status !== "ready" || !attachment.path) {
1088
1275
  const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
1089
1276
  if (attachment.status === "pending") {
1090
- return appendReference(originalBody, `[${label} pending: ${name2}]`);
1277
+ return appendReference(originalBody, `[${label} pending: ${name3}]`);
1091
1278
  }
1092
1279
  if (attachment.status === "expired") {
1093
- return appendReference(originalBody, `[${label} expired: ${name2}; no cached copy available. Ask the sender to resend.]`);
1280
+ return appendReference(originalBody, `[${label} expired: ${name3}; no cached copy available. Ask the sender to resend.]`);
1094
1281
  }
1095
- return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
1282
+ return appendReference(originalBody, `[${label} unavailable: ${name3} (${attachment.status})]`);
1096
1283
  }
1097
1284
  if (attachment.kind === "file") {
1098
- const prefix = originalBody.trim() === name2 ? "" : originalBody;
1099
- return appendReference(prefix, `[File: ${name2}]
1285
+ const prefix = originalBody.trim() === name3 ? "" : originalBody;
1286
+ return appendReference(prefix, `[File: ${name3}]
1100
1287
  [Local file: <${attachment.path}>]`);
1101
1288
  }
1102
1289
  if (exposeNonVisualPath) {
@@ -1164,15 +1351,33 @@ async function submitNativeText(client, accountId, threadId2, text3, key, option
1164
1351
 
1165
1352
  // src/native-recall-state.ts
1166
1353
  function nativeRecallInfo(message) {
1354
+ if (message.recall) validateNativeRecallMetadata(message.recall);
1167
1355
  return message.recall;
1168
1356
  }
1169
1357
  function nativeRecallIdentity(threadId2, serverId) {
1170
1358
  return JSON.stringify([threadId2, serverId]);
1171
1359
  }
1172
1360
  function validateNativeRecall(value) {
1361
+ validateNativeRecallMetadata(value);
1173
1362
  if (!value || typeof value.threadId !== "string" || !value.threadId || value.threadId.length > 4096 || typeof value.serverId !== "string" || !/^[1-9]\d{0,19}$/.test(value.serverId) || BigInt(value.serverId) > 18446744073709551615n || typeof value.observedAt !== "string" || value.observedAt.length > 128 || !Number.isFinite(Date.parse(value.observedAt)) || value.recalledAt !== void 0 && (typeof value.recalledAt !== "string" || value.recalledAt.length > 128 || !Number.isFinite(Date.parse(value.recalledAt))) || value.messageId !== void 0 && (typeof value.messageId !== "string" || !value.messageId || value.messageId.length > 4096)) throw Error("Invalid native recall observation; cursor retained");
1174
1363
  }
1364
+ function mergeNativeRecall(prior, next) {
1365
+ validateNativeRecall(prior);
1366
+ validateNativeRecall(next);
1367
+ if (prior.threadId !== next.threadId || prior.serverId !== next.serverId) throw Error("Conflicting native recall identity; cursor retained");
1368
+ return {
1369
+ threadId: prior.threadId,
1370
+ serverId: prior.serverId,
1371
+ observedAt: prior.observedAt,
1372
+ ...prior.messageId ?? next.messageId ? { messageId: prior.messageId ?? next.messageId } : {},
1373
+ ...prior.recalledAt ?? next.recalledAt ? { recalledAt: prior.recalledAt ?? next.recalledAt } : {},
1374
+ ...(prior.replacementText ?? next.replacementText) != null ? { replacementText: prior.replacementText ?? next.replacementText } : {}
1375
+ };
1376
+ }
1175
1377
  function recalledNativeMessage(message, recall) {
1378
+ validateNativeRecall(recall);
1379
+ const previous = nativeRecallInfo(message);
1380
+ const replacementText = previous?.replacementText ?? recall.replacementText;
1176
1381
  return {
1177
1382
  ...message,
1178
1383
  type: 1e4,
@@ -1180,8 +1385,14 @@ function recalledNativeMessage(message, recall) {
1180
1385
  quote: void 0,
1181
1386
  link: void 0,
1182
1387
  media: [],
1388
+ senderId: message.senderId || null,
1389
+ direction: message.senderId ? message.direction : null,
1183
1390
  mentions: { state: "unknown", userIds: [], self: null, all: null, source: null },
1184
- recall: { observedAt: recall.observedAt, ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {} }
1391
+ recall: {
1392
+ observedAt: previous?.observedAt ?? recall.observedAt,
1393
+ ...previous?.recalledAt ?? recall.recalledAt ? { recalledAt: previous?.recalledAt ?? recall.recalledAt } : {},
1394
+ ...replacementText != null ? { replacementText } : {}
1395
+ }
1185
1396
  };
1186
1397
  }
1187
1398
 
@@ -1192,6 +1403,26 @@ function kind(type) {
1192
1403
  var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "partial", "cancelled", "expired", "needs_review"]);
1193
1404
  var successfulSendJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded"]);
1194
1405
  var terminalIdentityErrors = /* @__PURE__ */ new Set(["media_identity_unavailable", "media_source_changed"]);
1406
+ var NativeMediaJobInspectionError = class extends Error {
1407
+ code = "native_media_job_inspection_required";
1408
+ retryable = false;
1409
+ jobId;
1410
+ accountId;
1411
+ threadId;
1412
+ idempotencyKey;
1413
+ state;
1414
+ reason;
1415
+ constructor(job, accountId, threadId2, key, reason) {
1416
+ super(`Native media job ${job.id} requires inspection (${job.state}); retain the original job and idempotency key and do not automatically resend`);
1417
+ this.name = "NativeMediaJobInspectionError";
1418
+ this.jobId = job.id;
1419
+ this.accountId = accountId;
1420
+ this.threadId = threadId2;
1421
+ this.idempotencyKey = key;
1422
+ this.state = job.state;
1423
+ this.reason = reason;
1424
+ }
1425
+ };
1195
1426
  function mediaSendResult(job) {
1196
1427
  return job.result && typeof job.result === "object" && !Array.isArray(job.result) ? job.result : {};
1197
1428
  }
@@ -1227,6 +1458,9 @@ async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial
1227
1458
  return job;
1228
1459
  }
1229
1460
  async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1461
+ return waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, (job, pollSignal) => client.job(accountId, job.id, pollSignal));
1462
+ }
1463
+ async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, readStatus) {
1230
1464
  const deadline = Date.now() + timeoutMs;
1231
1465
  let job = initial;
1232
1466
  let delay = 250;
@@ -1238,11 +1472,27 @@ async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3,
1238
1472
  if (remaining <= 0) break;
1239
1473
  signal?.throwIfAborted();
1240
1474
  assertCurrent?.();
1241
- job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1475
+ const next = await readStatus(job, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1476
+ if (!next) break;
1477
+ job = next;
1242
1478
  delay = Math.min(delay * 2, 1e3);
1243
1479
  }
1244
1480
  return job;
1245
1481
  }
1482
+ async function incomingMediaJobStatus(client, accountId, jobId, assetId, signal, assertCurrent) {
1483
+ let job;
1484
+ assertCurrent();
1485
+ try {
1486
+ job = await client.job(accountId, jobId, signal);
1487
+ } catch (error) {
1488
+ assertCurrent();
1489
+ if (error instanceof NativeApiError ? error.retryable && !terminalIdentityErrors.has(error.code) && error.code !== "message_recalled" : error instanceof TypeError || error instanceof DOMException && ["TimeoutError", "NetworkError", "AbortError"].includes(error.name)) return void 0;
1490
+ throw error;
1491
+ }
1492
+ assertCurrent();
1493
+ if (job.id !== jobId || job.accountId !== accountId || job.kind !== "download" || assetId != null && job.assetId != null && job.assetId !== assetId) throw Error("Native download job identity does not match its attachment");
1494
+ return job;
1495
+ }
1246
1496
  function select(assets, type, quality = "best") {
1247
1497
  const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
1248
1498
  const rank = (asset) => {
@@ -1419,7 +1669,10 @@ async function prepareNativeAttachmentInput(params) {
1419
1669
  if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
1420
1670
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1421
1671
  const accountId = account.native.accountId;
1422
- const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
1672
+ const maxBytes = Math.min(
1673
+ account.mediaMaxMb * 1024 * 1024,
1674
+ attachmentKind === "file" ? Math.max(NATIVE_MAX_SOURCE_BYTES, 128 * 1024 * 1024) : 128 * 1024 * 1024
1675
+ );
1423
1676
  const scopedAssets = async () => {
1424
1677
  await authorize();
1425
1678
  const items = (await client.mediaAssets(accountId, message.id)).items;
@@ -1445,9 +1698,9 @@ async function prepareNativeAttachmentInput(params) {
1445
1698
  let transferError;
1446
1699
  if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1447
1700
  await authorize();
1448
- job = await client.job(accountId, initialCandidate.jobId, params.signal);
1701
+ job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard);
1449
1702
  guard();
1450
- if (job.accountId !== accountId || job.kind !== "download" || job.assetId != null && job.assetId !== initialCandidate.id) throw Error("Native download job identity does not match its attachment");
1703
+ if (!job) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: initialCandidate.jobId, retryable: true };
1451
1704
  assets = await scopedAssets();
1452
1705
  asset = select(assets, message.type, quality);
1453
1706
  if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
@@ -1458,7 +1711,13 @@ async function prepareNativeAttachmentInput(params) {
1458
1711
  try {
1459
1712
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1460
1713
  guard();
1461
- job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1714
+ job = await waitNativeMediaStatus(
1715
+ job,
1716
+ params.waitMs ?? 2e4,
1717
+ params.signal,
1718
+ guard,
1719
+ (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard)
1720
+ );
1462
1721
  guard();
1463
1722
  } catch (error) {
1464
1723
  guard();
@@ -1491,6 +1750,55 @@ async function prepareNativeAttachmentInput(params) {
1491
1750
  return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1492
1751
  }
1493
1752
  await authorize();
1753
+ if (attachmentKind === "file" && asset.byteLength != null && asset.byteLength > 128 * 1024 * 1024) {
1754
+ const content2 = await client.mediaContentStream(accountId, asset.id, { maxBytes, signal: params.signal });
1755
+ const mime2 = content2.contentType.split(";", 1)[0].trim().toLowerCase();
1756
+ if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime2)) {
1757
+ await content2.cancel();
1758
+ throw Error("Native attachment MIME type is unsupported");
1759
+ }
1760
+ let saved2;
1761
+ try {
1762
+ saved2 = await saveVerifiedManagedAttachmentStream({
1763
+ stream: content2.stream,
1764
+ mime: mime2,
1765
+ filename,
1766
+ maxBytes,
1767
+ expectedBytes: asset.byteLength,
1768
+ expectedSha256: asset.sha256 ?? "",
1769
+ assertCurrent: guard,
1770
+ saveMediaStream: params.saveMediaStream ?? saveMediaStream
1771
+ });
1772
+ } finally {
1773
+ await content2.cancel();
1774
+ }
1775
+ await authorize();
1776
+ const attachment2 = { kind: "file", filename, status: "ready", path: saved2.path, mime: mime2 };
1777
+ return {
1778
+ body: renderAttachmentBody("", attachment2),
1779
+ attachment: attachment2,
1780
+ mediaMime: mime2,
1781
+ pending: false,
1782
+ jobId: job?.id,
1783
+ retryable: false,
1784
+ variant: asset.actualVariant ?? asset.variant,
1785
+ suppliedAssets: [{
1786
+ id: asset.id,
1787
+ contextRole: "trigger",
1788
+ role: "file_reference",
1789
+ kind: "file",
1790
+ path: saved2.path,
1791
+ mime: mime2,
1792
+ sha256: saved2.sha256,
1793
+ byteLength: saved2.size,
1794
+ runtimeMedia: false,
1795
+ sourceSha256: saved2.sha256,
1796
+ sourceByteLength: saved2.size,
1797
+ sourceMime: mime2,
1798
+ sourcePath: saved2.path
1799
+ }]
1800
+ };
1801
+ }
1494
1802
  const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1495
1803
  guard();
1496
1804
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
@@ -1498,7 +1806,7 @@ async function prepareNativeAttachmentInput(params) {
1498
1806
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
1499
1807
  if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
1500
1808
  await authorize();
1501
- const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1809
+ const saved = await saveManagedAttachmentBytes({ bytes: content.bytes, mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1502
1810
  const actual = asset.actualVariant ?? asset.variant;
1503
1811
  const attachment = {
1504
1812
  kind: attachmentKind,
@@ -1570,78 +1878,114 @@ async function prepareNativeAttachmentInput(params) {
1570
1878
  pending: false
1571
1879
  };
1572
1880
  }
1573
- async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
1574
- const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
1575
- let bytes;
1576
- let filename;
1577
- let mime;
1578
- if (/^https?:/.test(mediaUrl)) {
1579
- const response = await fetch(mediaUrl, { redirect: "error", signal: AbortSignal.timeout(3e4) });
1580
- if (!response.ok) throw new Error(`Native outbound source unavailable (${response.status})`);
1581
- const length = response.headers.get("content-length");
1582
- if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
1583
- await response.body?.cancel();
1584
- throw new Error("Native outbound source exceeds configured size");
1585
- }
1586
- const reader = response.body?.getReader();
1587
- if (!reader) throw new Error("Native outbound source has no bytes");
1588
- const chunks2 = [];
1589
- let size = 0;
1590
- try {
1591
- while (true) {
1592
- const { value, done } = await reader.read();
1593
- if (done) break;
1594
- size += value.length;
1595
- if (size > max) throw new Error("Native outbound source exceeds configured size");
1596
- chunks2.push(value);
1881
+ function validateNativeMediaSendOptions(options, audioAsVoice = false) {
1882
+ if (options.forceDocument != null && typeof options.forceDocument !== "boolean") throw new Error("Invalid WeChat document option");
1883
+ if (options.videoQuality != null && !["normal", "original"].includes(options.videoQuality)) throw new Error("Invalid WeChat video quality");
1884
+ if (options.forceDocument && (audioAsVoice || options.videoQuality != null)) throw new Error("Document sending cannot also request voice or video quality");
1885
+ }
1886
+ function outboundMediaKind(mime, audioAsVoice, options) {
1887
+ if (options.forceDocument) return "file";
1888
+ if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1889
+ const selected = audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file";
1890
+ if (options.videoQuality != null && selected !== "video") throw new Error("Video quality requires a video attachment");
1891
+ return selected;
1892
+ }
1893
+ function outboundSourceLimit(account, selectedKind) {
1894
+ return Math.min(account.mediaMaxMb * 1024 * 1024, selectedKind === "file" ? NATIVE_MAX_SOURCE_BYTES : selectedKind === "video" ? NATIVE_MAX_VIDEO_SOURCE_BYTES : NATIVE_MAX_ORDINARY_SOURCE_BYTES);
1895
+ }
1896
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend, options = {}) {
1897
+ validateNativeMediaSendOptions(options, audioAsVoice);
1898
+ await beforeSend?.();
1899
+ let filename, mime, selectedKind, sourceBytes, source;
1900
+ let temporary;
1901
+ const accountId = account.native.accountId;
1902
+ try {
1903
+ let blob;
1904
+ if (/^https?:/.test(mediaUrl)) {
1905
+ const response = await fetch(mediaUrl, { redirect: "error", signal: AbortSignal.timeout(60 * 6e4) });
1906
+ if (!response.ok) throw new Error(`Native outbound source unavailable (${response.status})`);
1907
+ filename = basename(new URL(mediaUrl).pathname) || "attachment";
1908
+ mime = response.headers.get("content-type")?.split(";")[0].trim().toLowerCase() ?? "application/octet-stream";
1909
+ try {
1910
+ selectedKind = outboundMediaKind(mime, audioAsVoice, options);
1911
+ } catch (error) {
1912
+ await response.body?.cancel();
1913
+ throw error;
1597
1914
  }
1598
- } catch (error) {
1599
- await reader.cancel();
1600
- throw error;
1601
- } finally {
1602
- reader.releaseLock();
1603
- }
1604
- bytes = new Uint8Array(size);
1605
- let offset = 0;
1606
- for (const chunk of chunks2) {
1607
- bytes.set(chunk, offset);
1608
- offset += chunk.length;
1609
- }
1610
- filename = basename(new URL(mediaUrl).pathname) || "attachment";
1611
- mime = response.headers.get("content-type")?.split(";")[0] ?? "application/octet-stream";
1612
- } else {
1613
- const path = mediaUrl.startsWith("file:") ? new URL(mediaUrl) : mediaUrl;
1614
- const file = await open(path, "r");
1615
- try {
1616
- const stat = await file.stat();
1617
- if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1618
- const chunks2 = [];
1619
- let size = 0;
1620
- while (true) {
1621
- const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
1622
- const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
1623
- if (!bytesRead) break;
1624
- size += bytesRead;
1625
- if (size > max) throw new Error("Native outbound source exceeds configured size");
1626
- chunks2.push(chunk.subarray(0, bytesRead));
1915
+ const max = outboundSourceLimit(account, selectedKind);
1916
+ const length = response.headers.get("content-length");
1917
+ if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
1918
+ await response.body?.cancel();
1919
+ throw new Error("Native outbound source exceeds configured size");
1627
1920
  }
1628
- bytes = Buffer.concat(chunks2, size);
1629
- } finally {
1630
- await file.close();
1921
+ const reader = response.body?.getReader();
1922
+ if (!reader) throw new Error("Native outbound source has no bytes");
1923
+ let path, file, size = 0;
1924
+ try {
1925
+ temporary = await mkdtemp(join2(tmpdir(), "wechat-outbound-"));
1926
+ path = join2(temporary, "source");
1927
+ file = await open(path, "wx", 384);
1928
+ while (true) {
1929
+ const { value, done } = await reader.read();
1930
+ if (done) break;
1931
+ size += value.byteLength;
1932
+ if (size > max) throw new Error("Native outbound source exceeds configured size");
1933
+ await file.writeFile(value);
1934
+ }
1935
+ if (!size || length && size !== Number(length)) throw new Error("Native outbound source length mismatch");
1936
+ } finally {
1937
+ await reader.cancel().catch(() => {
1938
+ });
1939
+ reader.releaseLock();
1940
+ await file?.close();
1941
+ }
1942
+ blob = await openAsBlob(path, { type: mime });
1943
+ } else {
1944
+ const path = mediaUrl.startsWith("file:") ? new URL(mediaUrl) : mediaUrl;
1945
+ filename = basename(path instanceof URL ? path.pathname : path);
1946
+ const extension = extname(filename).toLowerCase();
1947
+ mime = [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extension) ? `image/${extension === ".jpg" ? "jpeg" : extension.slice(1)}` : extension === ".mp4" ? "video/mp4" : [".wav", ".mp3", ".ogg", ".m4a", ".flac", ".aac"].includes(extension) ? `audio/${extension.slice(1)}` : "application/octet-stream";
1948
+ selectedKind = outboundMediaKind(mime, audioAsVoice, options);
1949
+ const max = outboundSourceLimit(account, selectedKind), file = await open(path, "r");
1950
+ let size;
1951
+ try {
1952
+ const stat = await file.stat();
1953
+ if (!stat.isFile() || !stat.size || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1954
+ size = stat.size;
1955
+ } finally {
1956
+ await file.close();
1957
+ }
1958
+ blob = await openAsBlob(path, { type: mime });
1959
+ if (blob.size !== size) throw new Error("Native outbound source changed before staging");
1631
1960
  }
1632
- filename = basename(path instanceof URL ? path.pathname : path);
1633
- const extension = extname(filename).toLowerCase();
1634
- mime = [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extension) ? `image/${extension === ".jpg" ? "jpeg" : extension.slice(1)}` : extension === ".mp4" ? "video/mp4" : [".wav", ".mp3", ".ogg", ".m4a", ".flac", ".aac"].includes(extension) ? `audio/${extension.slice(1)}` : "application/octet-stream";
1961
+ sourceBytes = blob.size;
1962
+ if (sourceBytes > outboundSourceLimit(account, selectedKind)) throw new Error("Native outbound source exceeds selected media kind size");
1963
+ await beforeSend?.();
1964
+ source = await client.upload(accountId, blob, filename, mime);
1965
+ } finally {
1966
+ if (temporary) await rm(temporary, { recursive: true, force: true });
1635
1967
  }
1636
- if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1637
- await beforeSend?.();
1638
- const accountId = account.native.accountId;
1639
- const source = await client.upload(accountId, bytes, filename, mime);
1640
- const input = { threadId: threadId2, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
1968
+ const input = {
1969
+ threadId: threadId2,
1970
+ kind: selectedKind,
1971
+ sourceId: source.id,
1972
+ standardSourceId: null,
1973
+ thumbnailSourceId: null,
1974
+ playSeconds: null,
1975
+ durationMs: null,
1976
+ ...selectedKind === "video" ? { videoQuality: options.videoQuality ?? "normal" } : {}
1977
+ };
1641
1978
  await beforeSend?.();
1642
- let job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1979
+ const waitMs = audioAsVoice ? 25 * 6e4 : selectedKind === "video" || selectedKind === "file" && sourceBytes > NATIVE_MAX_ORDINARY_SOURCE_BYTES ? 60 * 6e4 : 65e3;
1980
+ const submitted = await client.sendMedia(accountId, input, key);
1981
+ let job;
1982
+ try {
1983
+ job = await waitNativeMediaJob(client, accountId, submitted, waitMs);
1984
+ } catch {
1985
+ throw new NativeMediaJobInspectionError(submitted, accountId, threadId2, key, "status_unavailable");
1986
+ }
1643
1987
  if (successfulSendJobs.has(job.state)) job = await waitNativeMediaSendReceipts(client, accountId, threadId2, job, audioAsVoice);
1644
- if (!successfulSendJobs.has(job.state)) throw new Error(`Native media job ${job.id} requires inspection (${job.state}); retain completed items and do not automatically resend`);
1988
+ if (!successfulSendJobs.has(job.state)) throw new NativeMediaJobInspectionError(job, accountId, threadId2, key, "job_not_completed");
1645
1989
  const result = mediaSendResult(job);
1646
1990
  const chunks = Array.isArray(result.chunks) ? result.chunks.filter((chunk) => chunk && typeof chunk === "object") : [];
1647
1991
  const serverIds = audioAsVoice ? chunks.filter((chunk) => chunk.state === "accepted" && typeof chunk.serverMessageId === "string").map((chunk) => chunk.serverMessageId) : job.serverMessageId ? [job.serverMessageId] : void 0;
@@ -1649,13 +1993,16 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1649
1993
  }
1650
1994
 
1651
1995
  // src/outbound-media.ts
1652
- async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
1996
+ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey, options = {}, beforeSend) {
1997
+ validateNativeMediaSendOptions(options, audioAsVoice);
1998
+ if (!mediaUrl && (options.forceDocument || options.videoQuality != null)) throw new Error("WeChat media options require an attachment");
1653
1999
  const account = resolveWeChatAccount(cfg);
1654
2000
  if (!account?.native) throw new Error("No native daemon configured");
1655
2001
  const { client } = await ensureNativeHost(account.native);
1656
2002
  const key = idempotencyKey ?? randomUUID2();
1657
2003
  if (!mediaUrl) {
1658
2004
  if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
2005
+ await beforeSend?.();
1659
2006
  const send = await submitNativeText(client, account.native.accountId, to, text3, key);
1660
2007
  return nativeOutboundDelivery(
1661
2008
  client,
@@ -1665,26 +2012,116 @@ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, i
1665
2012
  { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
1666
2013
  );
1667
2014
  }
1668
- const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
1669
- if (text3) await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
2015
+ const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key, beforeSend, options);
2016
+ if (text3) {
2017
+ await beforeSend?.();
2018
+ await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
2019
+ }
1670
2020
  return result;
1671
2021
  }
1672
2022
 
2023
+ // src/outbound-options.ts
2024
+ function nativePayloadMediaOptions(payload) {
2025
+ const value = payload.channelData?.["agent-wechat"];
2026
+ if (value == null) return {};
2027
+ if (typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "videoQuality") || !["normal", "original"].includes(value.videoQuality)) throw Error("Invalid WeChat media options");
2028
+ return { videoQuality: value.videoQuality };
2029
+ }
2030
+ var wechatMediaActions = {
2031
+ describeMessageTool: () => ({ actions: ["send"], schema: { actions: ["send"], visibility: "all-configured", properties: {
2032
+ channelData: { type: "object", description: "WeChat per-send media options. Use agent-wechat.videoQuality for a single video attachment.", additionalProperties: true, properties: {
2033
+ "agent-wechat": { type: "object", additionalProperties: false, required: ["videoQuality"], properties: {
2034
+ videoQuality: { type: "string", enum: ["normal", "original"], description: "WeChat video mode: normal prepares playback; original also retains the unchanged source video. Cannot combine with forceDocument." }
2035
+ } }
2036
+ } }
2037
+ } } }),
2038
+ prepareSendPayload: ({ ctx, payload }) => {
2039
+ const options = nativePayloadMediaOptions(payload);
2040
+ if (options.videoQuality == null) return void 0;
2041
+ validateNativeMediaSendOptions({ ...options, forceDocument: ctx.params.forceDocument ?? ctx.params.asDocument }, payload.audioAsVoice);
2042
+ return payload;
2043
+ }
2044
+ };
2045
+ var NativeMediaPayloadInspectionError = class extends Error {
2046
+ retryable = false;
2047
+ code = "native_media_payload_inspection_required";
2048
+ deliveries;
2049
+ pendingJob;
2050
+ constructor(deliveries, pendingJob) {
2051
+ super("WeChat attachment batch requires inspection; retain completed receipts and do not automatically resend");
2052
+ this.name = "NativeMediaPayloadInspectionError";
2053
+ this.deliveries = deliveries;
2054
+ this.pendingJob = pendingJob;
2055
+ }
2056
+ };
2057
+ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2058
+ const key = (ctx, index = 0) => ctx.deliveryQueueId ? createHash6("sha256").update(JSON.stringify(["wechat-media", ctx.deliveryQueueId, ctx.deliveryPartIndex ?? 0, index])).digest("hex") : void 0;
2059
+ const guard = (ctx) => async () => {
2060
+ ctx.signal?.throwIfAborted();
2061
+ await ctx.onPlatformSendDispatch?.();
2062
+ ctx.signal?.throwIfAborted();
2063
+ ctx.assertDirectAdapterHandoff?.();
2064
+ };
2065
+ return {
2066
+ sendMedia: async (ctx) => ({
2067
+ channel: "agent-wechat",
2068
+ ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard(ctx))
2069
+ }),
2070
+ sendPayload: async (ctx) => {
2071
+ const options = { ...nativePayloadMediaOptions(ctx.payload), forceDocument: ctx.forceDocument };
2072
+ const audioAsVoice = ctx.payload.audioAsVoice ?? ctx.audioAsVoice;
2073
+ validateNativeMediaSendOptions(options, audioAsVoice);
2074
+ const urls = [...new Set([...ctx.payload.mediaUrls ?? [], ctx.payload.mediaUrl ?? ctx.mediaUrl].filter((url2) => typeof url2 === "string" && !!url2))];
2075
+ if (urls.length > 16 || options.videoQuality != null && urls.length !== 1) throw Error("WeChat video quality requires one attachment per send");
2076
+ const deliveries = [];
2077
+ for (const [index, mediaUrl] of (urls.length ? urls : [void 0]).entries()) {
2078
+ let delivered;
2079
+ try {
2080
+ delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard(ctx));
2081
+ } catch (error) {
2082
+ if (!deliveries.length) throw error;
2083
+ throw new NativeMediaPayloadInspectionError(deliveries, error instanceof NativeMediaJobInspectionError ? { jobId: error.jobId, accountId: error.accountId, threadId: error.threadId, idempotencyKey: error.idempotencyKey } : void 0);
2084
+ }
2085
+ deliveries.push(delivered);
2086
+ if (urls.length > 1) {
2087
+ try {
2088
+ await ctx.onDeliveryResult?.({ channel: "agent-wechat", ...delivered });
2089
+ } catch {
2090
+ throw new NativeMediaPayloadInspectionError(deliveries);
2091
+ }
2092
+ }
2093
+ if (delivered.meta.outcome === "unknown") break;
2094
+ }
2095
+ const first = deliveries[0];
2096
+ return {
2097
+ channel: "agent-wechat",
2098
+ ...first,
2099
+ ...deliveries.length > 1 ? { meta: {
2100
+ ...first.meta,
2101
+ parts: deliveries,
2102
+ retryable: false,
2103
+ ...deliveries.some((part) => part.meta.outcome === "unknown") ? { outcome: "unknown", receiptState: "needs_review" } : {}
2104
+ } } : {}
2105
+ };
2106
+ }
2107
+ };
2108
+ }
2109
+
1673
2110
  // src/native-monitor.ts
1674
- import { createHash as createHash8 } from "node:crypto";
2111
+ import { createHash as createHash9 } from "node:crypto";
1675
2112
  import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
1676
2113
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
1677
2114
 
1678
2115
  // src/native-consumer-state.ts
1679
- import { createHash as createHash6, randomUUID as randomUUID3 } from "node:crypto";
2116
+ import { createHash as createHash7, randomUUID as randomUUID3 } from "node:crypto";
1680
2117
  import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1681
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2 } from "node:path";
2118
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3 } from "node:path";
1682
2119
  var NativeConsumerState = class {
1683
2120
  file;
1684
2121
  value;
1685
2122
  constructor(directory, serverId, accountId) {
1686
2123
  if (!isAbsolute2(directory) || !serverId || !accountId) throw new Error("Native consumer state requires absolute storage and bound server/account identities");
1687
- this.file = join2(directory, `${createHash6("sha256").update(`${serverId}\0${accountId}`).digest("hex")}.json`);
2124
+ this.file = join3(directory, `${createHash7("sha256").update(`${serverId}\0${accountId}`).digest("hex")}.json`);
1688
2125
  this.value = { schemaVersion: 1, serverId, accountId, cursor: "0", items: {} };
1689
2126
  if (existsSync(this.file)) {
1690
2127
  const stat = lstatSync(this.file);
@@ -1693,6 +2130,7 @@ var NativeConsumerState = class {
1693
2130
  if (parsed.schemaVersion !== 1 || parsed.serverId !== serverId || parsed.accountId !== accountId || !/^\d+$/.test(parsed.cursor) || !parsed.items || typeof parsed.items !== "object") throw new Error("Native consumer identity or schema mismatch; state was preserved");
1694
2131
  for (const [id2, item] of Object.entries(parsed.items)) {
1695
2132
  if (item.message?.id !== id2 || item.message.accountId !== accountId || typeof item.message.threadId !== "string" || !["received", "dispatching", "handed_off", "completed", "ignored", "needs_review"].includes(item.state)) throw new Error("Invalid native custody record; state was preserved");
2133
+ nativeRecallInfo(item.message);
1696
2134
  if (item.cursor != null && !/^\d+$/.test(item.cursor) || item.groupContext != null && typeof item.groupContext !== "boolean") throw new Error("Invalid native group context record; state was preserved");
1697
2135
  if (item.revision != null && (typeof item.revision !== "string" || !item.revision || item.revision.length > 64)) throw Error("Invalid native custody revision; state was preserved");
1698
2136
  item.revision ??= randomUUID3();
@@ -1822,13 +2260,8 @@ var NativeConsumerState = class {
1822
2260
  }
1823
2261
  const key = nativeRecallIdentity(recall.threadId, recall.serverId);
1824
2262
  this.value.recalls ??= {};
1825
- this.value.recalls[key] ??= {
1826
- threadId: recall.threadId,
1827
- serverId: recall.serverId,
1828
- observedAt: recall.observedAt,
1829
- ...recall.messageId ? { messageId: recall.messageId } : {},
1830
- ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {}
1831
- };
2263
+ const remembered = mergeNativeRecall(this.value.recalls[key] ?? recall, recall);
2264
+ this.value.recalls[key] = remembered;
1832
2265
  const affected = /* @__PURE__ */ new Set();
1833
2266
  for (const item of this.items()) {
1834
2267
  if (item.message.threadId !== recall.threadId) continue;
@@ -1836,7 +2269,7 @@ var NativeConsumerState = class {
1836
2269
  const quote = item.message.quote?.serverId === recall.serverId;
1837
2270
  if (!target && !quote) continue;
1838
2271
  affected.add(item.message.id);
1839
- item.message = target ? recalledNativeMessage(item.message, recall) : { ...item.message, quote: void 0 };
2272
+ item.message = target ? recalledNativeMessage(item.message, remembered) : { ...item.message, quote: void 0 };
1840
2273
  if (target) item.groupContext = false;
1841
2274
  item.readPending = false;
1842
2275
  if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
@@ -1907,7 +2340,7 @@ var NativeConsumerState = class {
1907
2340
  });
1908
2341
  const revisions = items.map((item) => item.revision);
1909
2342
  if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
1910
- const manifestHash = createHash6("sha256").update(JSON.stringify({
2343
+ const manifestHash = createHash7("sha256").update(JSON.stringify({
1911
2344
  accountId: this.value.accountId,
1912
2345
  messageIds: exact,
1913
2346
  dispatchManifests: items.map((item) => item.dispatchManifest ?? null)
@@ -1916,7 +2349,7 @@ var NativeConsumerState = class {
1916
2349
  const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
1917
2350
  if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
1918
2351
  this.mutate(() => {
1919
- const currentHash = createHash6("sha256").update(JSON.stringify({
2352
+ const currentHash = createHash7("sha256").update(JSON.stringify({
1920
2353
  accountId: this.value.accountId,
1921
2354
  messageIds: exact,
1922
2355
  dispatchManifests: exact.map((id2) => this.value.items[id2]?.dispatchManifest ?? null)
@@ -1935,11 +2368,11 @@ var NativeConsumerState = class {
1935
2368
  };
1936
2369
 
1937
2370
  // src/native-prepared-boundary.ts
1938
- import { createHash as createHash7, randomUUID as randomUUID4 } from "node:crypto";
2371
+ import { createHash as createHash8, randomUUID as randomUUID4 } from "node:crypto";
1939
2372
  import { basename as basename2, isAbsolute as isAbsolute3 } from "node:path";
1940
2373
  var pending = /* @__PURE__ */ new Map();
1941
2374
  var enabled = false;
1942
- var digest = (value) => createHash7("sha256").update(value).digest("hex");
2375
+ var digest = (value) => createHash8("sha256").update(value).digest("hex");
1943
2376
  function prune() {
1944
2377
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1945
2378
  }
@@ -1950,7 +2383,7 @@ function nativeDispatchManifest(params) {
1950
2383
  const history = params.history ?? [];
1951
2384
  if (history.length > 200 || history.some((message) => message.accountId !== params.message.accountId || message.threadId !== params.message.threadId || message.id === params.message.id)) throw Error("Invalid native history manifest");
1952
2385
  const assets = Object.freeze((params.assets ?? []).map((asset) => {
1953
- if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || !["trigger", "history"].includes(asset.contextRole) || asset.sourceMessageId != null && (typeof asset.sourceMessageId !== "string" || !asset.sourceMessageId || asset.sourceMessageId.length > 8192 || ![params.message, ...history].some((message) => message.id === asset.messageId && message.type === 49 && message.appMessageType === 57 && message.quote?.type === 3)) || asset.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
2386
+ if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > (asset.kind === "file" ? NATIVE_MAX_SOURCE_BYTES : 128 * 1024 * 1024) || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || !["trigger", "history"].includes(asset.contextRole) || asset.sourceMessageId != null && (typeof asset.sourceMessageId !== "string" || !asset.sourceMessageId || asset.sourceMessageId.length > 8192 || ![params.message, ...history].some((message) => message.id === asset.messageId && message.type === 49 && message.appMessageType === 57 && message.quote?.type === 3)) || asset.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
1954
2387
  return Object.freeze({ ...asset });
1955
2388
  }));
1956
2389
  const messageSid = nativeSourceMessageSid(params.message);
@@ -2463,12 +2896,12 @@ async function dispatchNativeMessageInput(params, revision) {
2463
2896
  if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
2464
2897
  if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
2465
2898
  for (const url2 of urls) {
2466
- const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2899
+ const key = createHash9("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2467
2900
  await sendNativeAttachment(client, account, message.threadId, url2, payload.audioAsVoice === true, key, beforeSend);
2468
2901
  }
2469
2902
  if (payload.text) {
2470
2903
  await beforeSend();
2471
- const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2904
+ const key = createHash9("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2472
2905
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
2473
2906
  }
2474
2907
  if (urls.length || payload.text) params.onDelivered?.();
@@ -2724,6 +3157,7 @@ async function sendWeChatText(cfg, to, text3) {
2724
3157
  if (!account?.serverUrl) throw new Error("No native daemon configured");
2725
3158
  return sendNativeText(account, to, text3);
2726
3159
  }
3160
+ var mediaHandlers = createWeChatMediaHandlers();
2727
3161
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2728
3162
  id: "agent-wechat",
2729
3163
  capabilities: {
@@ -2736,10 +3170,7 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2736
3170
  channel: "agent-wechat",
2737
3171
  ...await sendWeChatText(cfg, to, text3)
2738
3172
  }),
2739
- sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2740
- channel: "agent-wechat",
2741
- ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2742
- })
3173
+ ...mediaHandlers
2743
3174
  }
2744
3175
  });
2745
3176
  var meta = {
@@ -2763,6 +3194,7 @@ var wechatPlugin = {
2763
3194
  tts: { voice: { synthesisTarget: "voice-note", transcodesAudio: true } }
2764
3195
  },
2765
3196
  reload: { configPrefixes: ["channels.agent-wechat"] },
3197
+ actions: wechatMediaActions,
2766
3198
  configSchema: {
2767
3199
  uiHints: { "native.token": { sensitive: true } },
2768
3200
  schema: {
@@ -2884,10 +3316,7 @@ var wechatPlugin = {
2884
3316
  channel: "agent-wechat",
2885
3317
  ...await sendWeChatText(cfg, to, text3)
2886
3318
  }),
2887
- sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2888
- channel: "agent-wechat",
2889
- ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2890
- })
3319
+ ...mediaHandlers
2891
3320
  },
2892
3321
  message: wechatMessageAdapter,
2893
3322
  gateway: {
@@ -3054,7 +3483,7 @@ function createNativeFacade(config, dependencies = {}) {
3054
3483
  fail(res, 403, "facade_account_forbidden");
3055
3484
  return true;
3056
3485
  }
3057
- if (!["GET", "HEAD", "POST", "PATCH", "DELETE"].includes(req.method ?? "")) {
3486
+ if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(req.method ?? "")) {
3058
3487
  fail(res, 405, "method_not_allowed");
3059
3488
  return true;
3060
3489
  }
@@ -3067,19 +3496,23 @@ function createNativeFacade(config, dependencies = {}) {
3067
3496
  try {
3068
3497
  const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
3069
3498
  const headers = new Headers();
3070
- for (const name2 of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
3071
- const value = req.headers[name2];
3072
- if (typeof value === "string") headers.set(name2, value);
3499
+ for (const name3 of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
3500
+ const value = req.headers[name3];
3501
+ if (typeof value === "string") headers.set(name3, value);
3073
3502
  }
3503
+ const upload = pieces.length === 4 && pieces[3] === "uploads" && req.method === "POST";
3504
+ const mediaContent = pieces.length === 6 && pieces[3] === "media" && pieces[5] === "content" && parsed.searchParams.get("representation") !== "preview";
3505
+ const requestLimit = upload ? NATIVE_MAX_SOURCE_BYTES + 64 * 1024 : MAX_BODY;
3506
+ const responseLimit = mediaContent ? Math.max(MAX_BODY, NATIVE_MAX_SOURCE_BYTES) : MAX_BODY;
3074
3507
  const length = req.headers["content-length"];
3075
- if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
3508
+ if (length && (!/^\d+$/.test(length) || Number(length) > requestLimit)) {
3076
3509
  fail(res, 413, "payload_too_large");
3077
3510
  return true;
3078
3511
  }
3079
3512
  let size = 0;
3080
3513
  const body = ["GET", "HEAD"].includes(req.method ?? "") ? void 0 : Readable.toWeb(req).pipeThrough(new NodeTransformStream({ transform(chunk, controller) {
3081
3514
  size += chunk.byteLength;
3082
- if (size > MAX_BODY) {
3515
+ if (size > requestLimit) {
3083
3516
  tooLarge = true;
3084
3517
  throw new Error("payload_too_large");
3085
3518
  }
@@ -3092,10 +3525,16 @@ function createNativeFacade(config, dependencies = {}) {
3092
3525
  headers.set("Authorization", `Bearer ${dependencies.token}`);
3093
3526
  response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
3094
3527
  }
3528
+ const responseLength = response.headers.get("content-length");
3529
+ if (responseLength && (!/^\d+$/.test(responseLength) || Number(responseLength) > responseLimit)) {
3530
+ await response.body?.cancel();
3531
+ fail(res, 502, "native_facade_unavailable");
3532
+ return true;
3533
+ }
3095
3534
  res.statusCode = response.status;
3096
- for (const name2 of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
3097
- const value = response.headers.get(name2);
3098
- if (value) res.setHeader(name2, value);
3535
+ for (const name3 of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
3536
+ const value = response.headers.get(name3);
3537
+ if (value) res.setHeader(name3, value);
3099
3538
  }
3100
3539
  res.setHeader("x-content-type-options", "nosniff");
3101
3540
  const reader = response.body?.getReader();
@@ -3110,7 +3549,7 @@ function createNativeFacade(config, dependencies = {}) {
3110
3549
  const { value, done } = await reader.read();
3111
3550
  if (done) break;
3112
3551
  emitted += value.length;
3113
- if (!sse && emitted > MAX_BODY) throw new Error("upstream_response_too_large");
3552
+ if (!sse && emitted > responseLimit) throw new Error("upstream_response_too_large");
3114
3553
  if (!res.write(value)) await drain(res, abort.signal);
3115
3554
  }
3116
3555
  } finally {
@@ -3154,8 +3593,8 @@ function object(value) {
3154
3593
  if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
3155
3594
  return value;
3156
3595
  }
3157
- function string(value, name2, max = 4096) {
3158
- if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name2}`);
3596
+ function string(value, name3, max = 4096) {
3597
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name3}`);
3159
3598
  return value.trim();
3160
3599
  }
3161
3600
  function threadId(value) {
@@ -3227,9 +3666,9 @@ function createLookupTools(context, dependencies, archive) {
3227
3666
  if (!currentThread) return [];
3228
3667
  }
3229
3668
  const selectedId = selected.native.accountId;
3230
- const tool = (name2, action, description, properties, required = []) => ({
3231
- name: name2,
3232
- label: name2.replaceAll("_", " "),
3669
+ const tool = (name3, action, description, properties, required = []) => ({
3670
+ name: name3,
3671
+ label: name3.replaceAll("_", " "),
3233
3672
  description,
3234
3673
  // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
3235
3674
  // public direct-only surface preserves actual image content for vision.
@@ -3530,7 +3969,7 @@ function registerNativeShareTools(api) {
3530
3969
  }
3531
3970
 
3532
3971
  // src/native-recall-tools.ts
3533
- import { createHash as createHash9 } from "node:crypto";
3972
+ import { createHash as createHash10 } from "node:crypto";
3534
3973
  import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress4 } from "openclaw/plugin-sdk/channel-ingress-runtime";
3535
3974
  var names2 = ["wechat_recall"];
3536
3975
  function text2(value, label) {
@@ -3541,7 +3980,7 @@ function thread2(value) {
3541
3980
  return text2(text2(value, "conversation").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "conversation");
3542
3981
  }
3543
3982
  function nativeRecallKey(accountId, threadId2, messageId, serverId) {
3544
- return `openclaw-recall-${createHash9("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
3983
+ return `openclaw-recall-${createHash10("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
3545
3984
  }
3546
3985
  function createNativeRecallTools(context, dependencies = {}) {
3547
3986
  const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
@@ -3664,6 +4103,55 @@ function registerNativeRecallTools(api) {
3664
4103
  api.registerTool({ contextVersion: 2, create: (context) => createNativeRecallTools(context) }, { names: names2 });
3665
4104
  }
3666
4105
 
4106
+ // src/native-group-tool.ts
4107
+ var name2 = "wechat_group_info";
4108
+ function createNativeGroupTools(context, dependencies = {}) {
4109
+ if (context.senderIsOwner !== true || typeof context.assertInvocationCurrent !== "function") return [];
4110
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
4111
+ const selected = resolveWeChatAccount(currentConfig());
4112
+ if (!selected?.enabled || !selected.native || context.agentAccountId && context.agentAccountId !== selected.accountId || context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
4113
+ const accountId = selected.native.accountId;
4114
+ const configurationKey = nativeConfigurationKey(selected.native);
4115
+ return [{
4116
+ name: name2,
4117
+ label: "WeChat Group Info",
4118
+ description: "Read cached group information and one page of members for the configured WeChat account. Available only to the trusted OpenClaw owner. Does not create groups, invite people or change settings. Cached member absence does not prove non-membership.",
4119
+ parameters: { type: "object", additionalProperties: false, properties: {
4120
+ groupId: { type: "string", minLength: 10, maxLength: 256, description: "Exact ordinary group username ending in @chatroom." },
4121
+ limit: { type: "integer", minimum: 1, maximum: 100 },
4122
+ cursor: { type: "string", maxLength: 8192 },
4123
+ username: { type: "string", minLength: 1, maxLength: 256, description: "Optionally inspect one exact member instead of listing a page." }
4124
+ }, required: ["groupId"] },
4125
+ execute: async (_callId, input, signal) => {
4126
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((k) => !["groupId", "limit", "cursor", "username"].includes(k))) throw Error("Invalid group info request");
4127
+ const args = input;
4128
+ const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/u.test(value);
4129
+ if (!identity(args.groupId) || !args.groupId.endsWith("@chatroom") || args.groupId.length <= 9 || args.limit != null && (!Number.isInteger(args.limit) || Number(args.limit) < 1 || Number(args.limit) > 100) || args.cursor != null && (typeof args.cursor !== "string" || args.cursor.length > 8192) || args.username != null && !identity(args.username) || args.username != null && (args.limit != null || args.cursor != null)) throw Error("Invalid group info selectors");
4130
+ const guard = () => {
4131
+ signal?.throwIfAborted();
4132
+ if (context.senderIsOwner !== true) throw Error("OpenClaw owner authorization is required");
4133
+ context.assertInvocationCurrent();
4134
+ const account = resolveWeChatAccount(currentConfig());
4135
+ if (!account?.enabled || !account.native || account.accountId !== selected.accountId || account.native.accountId !== accountId || nativeConfigurationKey(account.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
4136
+ return account.native;
4137
+ };
4138
+ const native = guard();
4139
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4140
+ guard();
4141
+ const group = await client.group(accountId, args.groupId, signal);
4142
+ guard();
4143
+ if (group.id !== args.groupId || group.roomId !== args.groupId) throw Error("Group identity is invalid");
4144
+ const members = typeof args.username === "string" ? await client.groupMember(accountId, args.groupId, args.username, signal) : await client.groupMembers(accountId, args.groupId, { limit: args.limit == null ? 50 : Number(args.limit), cursor: args.cursor }, signal);
4145
+ guard();
4146
+ const details = { accountId, group, members, source: "cached", membershipAbsence: "unconfirmed" };
4147
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
4148
+ }
4149
+ }];
4150
+ }
4151
+ function registerNativeGroupTools(api) {
4152
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeGroupTools(context) }, { names: [name2] });
4153
+ }
4154
+
3667
4155
  // index.ts
3668
4156
  var plugin = {
3669
4157
  id: "agent-wechat",
@@ -3677,6 +4165,7 @@ var plugin = {
3677
4165
  registerNativeHistoryTools(api);
3678
4166
  registerNativeShareTools(api);
3679
4167
  registerNativeRecallTools(api);
4168
+ registerNativeGroupTools(api);
3680
4169
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
3681
4170
  };
3682
4171
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {