@agent-wechat/agent-wechat 1.0.0-beta.11 → 1.0.0-beta.13

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);
349
388
  }
350
- groupMembers(accountId, roomId, options) {
351
- return this.request(`v1/accounts/${id(accountId)}/groups/${id(roomId)}/members${listQuery(options)}`);
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);
403
+ }
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,15 +449,9 @@ 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
- /** Resolve only the stored original in this message's own conversation. */
397
- async quotedMessage(accountId, messageId) {
398
- 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))
400
- throw new Error("Invalid native quote identity");
401
- return quote;
402
- }
403
455
  /** Account-wide local search, with the daemon's query-bound opaque cursor. */
404
456
  async searchMessages(accountId, options) {
405
457
  const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
@@ -408,13 +460,20 @@ var NativeWeChatClient = class {
408
460
  continue;
409
461
  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
462
  throw new Error("Invalid native archive message identity");
463
+ validateMessageRecall(message);
411
464
  }
412
465
  return result;
413
466
  }
414
- sendText(accountId, threadId2, text3, idempotencyKey) {
467
+ /** Quote a locally stored text, image, voice, video, link, file or quoted reply in the same chat.
468
+ * Live acceptance and recipient rendering remain unverified; other source kinds are rejected.
469
+ */
470
+ sendText(accountId, threadId2, text3, idempotencyKey, quote) {
415
471
  if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
416
472
  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 }) });
473
+ 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))
474
+ throw new Error("Invalid quote message identity");
475
+ const input = quote === void 0 ? { threadId: threadId2, text: text3 } : { kind: "text", threadId: threadId2, text: text3, quote };
476
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify(input) });
418
477
  }
419
478
  send(accountId, sendId, signal) {
420
479
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
@@ -432,12 +491,16 @@ var NativeWeChatClient = class {
432
491
  cancelSend(accountId, sendId) {
433
492
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
434
493
  }
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))
494
+ /** A disk-backed Blob avoids holding the source in memory before staging. */
495
+ async upload(accountId, bytes, name3, contentType = "application/octet-stream", signal) {
496
+ const size = bytes instanceof Blob ? bytes.size : bytes.byteLength;
497
+ if (!size || size > NATIVE_MAX_SOURCE_BYTES || !name3 || name3.length > 255 || /[\r\n/\\]/.test(name3))
437
498
  throw new Error("Invalid bounded media upload");
499
+ 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
500
  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 });
501
+ form.set("file", part, name3);
502
+ const response = await this.response(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal }, false, this.mediaTimeoutMs);
503
+ return JSON.parse(new TextDecoder().decode(await this.readBounded(response)));
441
504
  }
442
505
  sendMedia(accountId, input, key) {
443
506
  if (!key || /[\r\n]/.test(key))
@@ -459,26 +522,94 @@ var NativeWeChatClient = class {
459
522
  cancelJob(accountId, jobId) {
460
523
  return this.request(`v1/accounts/${id(accountId)}/jobs/${id(jobId)}/cancel`, { method: "POST", body: "{}" });
461
524
  }
462
- retryJob(accountId, jobId) {
463
- return this.request(`v1/accounts/${id(accountId)}/jobs/${id(jobId)}/retry`, { method: "POST", body: "{}" });
525
+ retryJob(accountId, jobId, options = {}) {
526
+ if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => key !== "reuseCompletedUpload"))
527
+ throw new Error("Invalid media retry options");
528
+ const reuse = options.reuseCompletedUpload;
529
+ if (reuse !== void 0 && typeof reuse !== "boolean")
530
+ throw new Error("Invalid media retry options");
531
+ 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
532
  }
465
533
  repairMediaWriteback(accountId, limit = 25) {
466
534
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
467
535
  throw new Error("Invalid media writeback limit");
468
536
  return this.request(`v1/accounts/${id(accountId)}/media-writeback/repair`, { method: "POST", body: JSON.stringify({ limit }) });
469
537
  }
470
- async mediaContent(accountId, assetId, options = {}) {
538
+ /** Consume the bounded stream once, or cancel it; this never starts a CDN transfer. */
539
+ async mediaContentStream(accountId, assetId, options = {}) {
471
540
  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))
541
+ 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
542
  throw new Error("Invalid bounded content request");
474
543
  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 } : {} });
544
+ const cancellation = new AbortController();
545
+ 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
546
  const length = response.headers.get("content-length");
477
547
  if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
478
548
  await response.body?.cancel();
479
549
  throw new Error("Native media response exceeds its bound");
480
550
  }
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 };
551
+ if (!response.body && length && Number(length) > 0)
552
+ throw new Error("Native media response length mismatch");
553
+ let activeReader;
554
+ const iterate = async function* () {
555
+ cancellation.signal.throwIfAborted();
556
+ options.signal?.throwIfAborted();
557
+ const reader = response.body?.getReader();
558
+ if (!reader)
559
+ return;
560
+ activeReader = reader;
561
+ let size = 0;
562
+ try {
563
+ while (true) {
564
+ cancellation.signal.throwIfAborted();
565
+ options.signal?.throwIfAborted();
566
+ const { value, done } = await reader.read();
567
+ cancellation.signal.throwIfAborted();
568
+ options.signal?.throwIfAborted();
569
+ if (done)
570
+ break;
571
+ size += value.byteLength;
572
+ if (size > max)
573
+ throw new Error("Native media response exceeds its bound");
574
+ yield value;
575
+ }
576
+ if (length && size !== Number(length))
577
+ throw new Error("Native media response length mismatch");
578
+ } finally {
579
+ await reader.cancel().catch(() => {
580
+ });
581
+ reader.releaseLock();
582
+ activeReader = void 0;
583
+ }
584
+ };
585
+ let claimed = false;
586
+ const stream = { [Symbol.asyncIterator]() {
587
+ if (claimed)
588
+ throw new Error("Native media stream was already consumed");
589
+ claimed = true;
590
+ return iterate();
591
+ } };
592
+ return { stream, cancel: async () => {
593
+ cancellation.abort();
594
+ await (activeReader ? activeReader.cancel() : response.body?.cancel())?.catch(() => {
595
+ });
596
+ }, 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 };
597
+ }
598
+ async mediaContent(accountId, assetId, options = {}) {
599
+ const { stream, cancel: _cancel, ...metadata } = await this.mediaContentStream(accountId, assetId, options);
600
+ const chunks = [];
601
+ let size = 0;
602
+ for await (const chunk of stream) {
603
+ chunks.push(chunk);
604
+ size += chunk.byteLength;
605
+ }
606
+ const bytes = new Uint8Array(size);
607
+ let offset = 0;
608
+ for (const chunk of chunks) {
609
+ bytes.set(chunk, offset);
610
+ offset += chunk.byteLength;
611
+ }
612
+ return { ...metadata, bytes };
482
613
  }
483
614
  markRead(accountId, threadId2, expectedVersion) {
484
615
  if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
@@ -488,7 +619,12 @@ var NativeWeChatClient = class {
488
619
  events(accountId, after = "0", limit = 100) {
489
620
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
490
621
  throw new Error("Invalid event page limit");
491
- return this.request(`v1/accounts/${id(accountId)}/events?after=${decimal(after)}&limit=${limit}`);
622
+ return this.request(`v1/accounts/${id(accountId)}/events?after=${decimal(after)}&limit=${limit}`).then((page) => {
623
+ for (const event of page.items)
624
+ if (event.kind === "message.recalled")
625
+ validateNativeRecallMetadata(event.data);
626
+ return page;
627
+ });
492
628
  }
493
629
  ack(accountId, consumerId, cursor) {
494
630
  return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
@@ -552,6 +688,8 @@ var NativeWeChatClient = class {
552
688
  const event = payload;
553
689
  if (!event || typeof event.kind !== "string" || !event.data || typeof event.data !== "object")
554
690
  throw new Error("Invalid native event");
691
+ if (event.kind === "message.recalled")
692
+ validateNativeRecallMetadata(event.data);
555
693
  event.cursor = decimal(event.cursor ?? eventCursor);
556
694
  yield event;
557
695
  }
@@ -598,7 +736,7 @@ function requireNativeIdentity(value) {
598
736
  }
599
737
  }
600
738
  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]]));
739
+ 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
740
  }
603
741
  function nativeHostTarget(platform = process.platform, arch = process.arch) {
604
742
  const cpu = arch === "arm64" ? "aarch64" : "x86_64";
@@ -1034,15 +1172,21 @@ function resolveWeChatIngress(params) {
1034
1172
  });
1035
1173
  }
1036
1174
 
1175
+ // src/outbound-options.ts
1176
+ import { createHash as createHash6 } from "node:crypto";
1177
+
1037
1178
  // src/outbound-media.ts
1038
1179
  import { createHash as createHash5 } from "node:crypto";
1039
1180
  import { randomUUID as randomUUID2 } from "node:crypto";
1040
1181
 
1041
1182
  // src/native-media.ts
1042
1183
  import { createHash as createHash4, randomUUID } from "node:crypto";
1043
- import { open } from "node:fs/promises";
1044
- import { basename, extname } from "node:path";
1184
+ import { openAsBlob } from "node:fs";
1185
+ import { mkdtemp, open, rm } from "node:fs/promises";
1186
+ import { tmpdir } from "node:os";
1187
+ import { basename, extname, join as join2 } from "node:path";
1045
1188
  import { getImageMetadata, resizeToJpeg, IMAGE_REDUCE_QUALITY_STEPS } from "openclaw/plugin-sdk/media-runtime";
1189
+ import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
1046
1190
 
1047
1191
  // src/attachment-store.ts
1048
1192
  import { createHash as createHash3 } from "node:crypto";
@@ -1062,6 +1206,12 @@ async function saveManagedAttachment(params) {
1062
1206
  }
1063
1207
  const buffer = Buffer.from(params.data, "base64");
1064
1208
  if (buffer.byteLength !== size) throw new Error("Attachment decoding length mismatch");
1209
+ return saveManagedAttachmentBytes({ ...params, bytes: buffer });
1210
+ }
1211
+ async function saveManagedAttachmentBytes(params) {
1212
+ const size = params.bytes.byteLength;
1213
+ if (size > params.maxBytes) throw new AttachmentTooLargeError(`Attachment exceeds configured ${params.maxBytes} byte limit`);
1214
+ const buffer = Buffer.from(params.bytes.buffer, params.bytes.byteOffset, size);
1065
1215
  const sha256 = createHash3("sha256").update(buffer).digest("hex");
1066
1216
  const saved = await params.saveMediaBuffer(
1067
1217
  buffer,
@@ -1072,31 +1222,59 @@ async function saveManagedAttachment(params) {
1072
1222
  );
1073
1223
  return { path: saved.path, size, sha256, filename: params.filename };
1074
1224
  }
1225
+ async function saveVerifiedManagedAttachmentStream(params) {
1226
+ 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");
1227
+ let size = 0, finished = false;
1228
+ const digest2 = createHash3("sha256");
1229
+ const stream = (async function* () {
1230
+ for await (const chunk of params.stream) {
1231
+ params.assertCurrent();
1232
+ size += chunk.byteLength;
1233
+ if (size > params.maxBytes || size > params.expectedBytes) throw new AttachmentTooLargeError("Native attachment stream exceeds its bound");
1234
+ digest2.update(chunk);
1235
+ yield chunk;
1236
+ }
1237
+ params.assertCurrent();
1238
+ if (size !== params.expectedBytes || digest2.digest("hex") !== params.expectedSha256) throw new Error("Native attachment integrity did not verify");
1239
+ finished = true;
1240
+ })();
1241
+ const saved = await params.saveMediaStream(
1242
+ stream,
1243
+ params.mime,
1244
+ "inbound",
1245
+ params.maxBytes,
1246
+ params.filename,
1247
+ void 0,
1248
+ { assertCommitAllowed: params.assertCurrent, durable: true }
1249
+ );
1250
+ if (!finished || saved.size != null && saved.size !== size) throw new Error("Native attachment stream was not fully saved");
1251
+ return { path: saved.path, size, sha256: params.expectedSha256, filename: params.filename };
1252
+ }
1075
1253
 
1076
1254
  // src/media-delivery.ts
1077
1255
  function appendReference(body, reference) {
1078
1256
  return body ? `${body}
1079
1257
  ${reference}` : reference;
1080
1258
  }
1081
- function safeDisplayName(name2) {
1082
- return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1259
+ function safeDisplayName(name3) {
1260
+ return name3.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1083
1261
  }
1084
1262
  function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
1085
1263
  if (!attachment) return originalBody;
1086
- const name2 = safeDisplayName(attachment.filename);
1264
+ const name3 = safeDisplayName(attachment.filename);
1087
1265
  if (attachment.status !== "ready" || !attachment.path) {
1088
1266
  const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
1089
1267
  if (attachment.status === "pending") {
1090
- return appendReference(originalBody, `[${label} pending: ${name2}]`);
1268
+ return appendReference(originalBody, `[${label} pending: ${name3}]`);
1091
1269
  }
1092
1270
  if (attachment.status === "expired") {
1093
- return appendReference(originalBody, `[${label} expired: ${name2}; no cached copy available. Ask the sender to resend.]`);
1271
+ return appendReference(originalBody, `[${label} expired: ${name3}; no cached copy available. Ask the sender to resend.]`);
1094
1272
  }
1095
- return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
1273
+ return appendReference(originalBody, `[${label} unavailable: ${name3} (${attachment.status})]`);
1096
1274
  }
1097
1275
  if (attachment.kind === "file") {
1098
- const prefix = originalBody.trim() === name2 ? "" : originalBody;
1099
- return appendReference(prefix, `[File: ${name2}]
1276
+ const prefix = originalBody.trim() === name3 ? "" : originalBody;
1277
+ return appendReference(prefix, `[File: ${name3}]
1100
1278
  [Local file: <${attachment.path}>]`);
1101
1279
  }
1102
1280
  if (exposeNonVisualPath) {
@@ -1150,13 +1328,13 @@ async function nativeOutboundDelivery(client, accountId, threadId2, messageIds,
1150
1328
  async function submitNativeText(client, accountId, threadId2, text3, key, options = {}) {
1151
1329
  let intent = await client.sendText(accountId, threadId2, text3, key);
1152
1330
  const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1153
- let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1331
+ let delay2 = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1154
1332
  while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1155
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1333
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, deadline - Date.now())));
1156
1334
  const remaining = deadline - Date.now();
1157
1335
  if (remaining <= 0) break;
1158
1336
  intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1159
- delay = Math.min(delay * 2, 1e3);
1337
+ delay2 = Math.min(delay2 * 2, 1e3);
1160
1338
  }
1161
1339
  if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1162
1340
  return intent;
@@ -1164,15 +1342,33 @@ async function submitNativeText(client, accountId, threadId2, text3, key, option
1164
1342
 
1165
1343
  // src/native-recall-state.ts
1166
1344
  function nativeRecallInfo(message) {
1345
+ if (message.recall) validateNativeRecallMetadata(message.recall);
1167
1346
  return message.recall;
1168
1347
  }
1169
1348
  function nativeRecallIdentity(threadId2, serverId) {
1170
1349
  return JSON.stringify([threadId2, serverId]);
1171
1350
  }
1172
1351
  function validateNativeRecall(value) {
1352
+ validateNativeRecallMetadata(value);
1173
1353
  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
1354
  }
1355
+ function mergeNativeRecall(prior, next) {
1356
+ validateNativeRecall(prior);
1357
+ validateNativeRecall(next);
1358
+ if (prior.threadId !== next.threadId || prior.serverId !== next.serverId) throw Error("Conflicting native recall identity; cursor retained");
1359
+ return {
1360
+ threadId: prior.threadId,
1361
+ serverId: prior.serverId,
1362
+ observedAt: prior.observedAt,
1363
+ ...prior.messageId ?? next.messageId ? { messageId: prior.messageId ?? next.messageId } : {},
1364
+ ...prior.recalledAt ?? next.recalledAt ? { recalledAt: prior.recalledAt ?? next.recalledAt } : {},
1365
+ ...(prior.replacementText ?? next.replacementText) != null ? { replacementText: prior.replacementText ?? next.replacementText } : {}
1366
+ };
1367
+ }
1175
1368
  function recalledNativeMessage(message, recall) {
1369
+ validateNativeRecall(recall);
1370
+ const previous = nativeRecallInfo(message);
1371
+ const replacementText = previous?.replacementText ?? recall.replacementText;
1176
1372
  return {
1177
1373
  ...message,
1178
1374
  type: 1e4,
@@ -1180,8 +1376,42 @@ function recalledNativeMessage(message, recall) {
1180
1376
  quote: void 0,
1181
1377
  link: void 0,
1182
1378
  media: [],
1379
+ senderId: message.senderId || null,
1380
+ direction: message.senderId ? message.direction : null,
1183
1381
  mentions: { state: "unknown", userIds: [], self: null, all: null, source: null },
1184
- recall: { observedAt: recall.observedAt, ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {} }
1382
+ recall: {
1383
+ observedAt: previous?.observedAt ?? recall.observedAt,
1384
+ ...previous?.recalledAt ?? recall.recalledAt ? { recalledAt: previous?.recalledAt ?? recall.recalledAt } : {},
1385
+ ...replacementText != null ? { replacementText } : {}
1386
+ }
1387
+ };
1388
+ }
1389
+
1390
+ // src/native-quote.ts
1391
+ function renderEmbeddedQuote(message, permitted = true, imageBody) {
1392
+ const comment = (message.body ?? "").slice(0, 16384);
1393
+ const source = message.quote;
1394
+ const unavailable = (state2) => ({
1395
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state2}]`,
1396
+ image: false,
1397
+ quote: { state: state2, type: source?.type, senderId: source?.senderId ?? void 0 }
1398
+ });
1399
+ if (!source) return unavailable("unavailable");
1400
+ if (!permitted) return unavailable("not permitted");
1401
+ const state = source.state ?? "missing";
1402
+ if (state === "recalled") return unavailable("recalled");
1403
+ if (!["embedded", "missing", "unsupported"].includes(state) || source.embeddedText != null && (typeof source.embeddedText !== "string" || source.embeddedText.length > 16384) || source.embeddedImage != null && typeof source.embeddedImage !== "boolean") return unavailable("invalid");
1404
+ const label = source.type === 1 ? "text" : source.type === 3 ? "image" : source.type === 34 ? "voice" : source.type === 43 ? "video" : source.type === 49 && source.appMessageType === 5 ? "link" : source.type === 49 && source.appMessageType === 6 ? "file" : "message";
1405
+ const image = state === "embedded" && source.type === 3 && source.embeddedImage === true;
1406
+ if (state === "embedded" && !image && !source.embeddedText) return unavailable("invalid");
1407
+ const content = state === "missing" ? "" : image ? imageBody ?? "[Image metadata embedded in quote; image not retrieved]" : source.embeddedText ?? "";
1408
+ const status = state === "unsupported" ? `[${label} attachment unavailable from embedded quote]` : state === "missing" ? "[Embedded quote content unavailable]" : "";
1409
+ return {
1410
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted ${label} from ${source.senderId ?? "unknown sender"}; embedded in reply]
1411
+ ${[content, status].filter(Boolean).join("\n")}
1412
+ [/Quoted message]`,
1413
+ image,
1414
+ quote: { state, type: source.type, senderId: source.senderId ?? void 0 }
1185
1415
  };
1186
1416
  }
1187
1417
 
@@ -1192,6 +1422,26 @@ function kind(type) {
1192
1422
  var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "partial", "cancelled", "expired", "needs_review"]);
1193
1423
  var successfulSendJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded"]);
1194
1424
  var terminalIdentityErrors = /* @__PURE__ */ new Set(["media_identity_unavailable", "media_source_changed"]);
1425
+ var NativeMediaJobInspectionError = class extends Error {
1426
+ code = "native_media_job_inspection_required";
1427
+ retryable = false;
1428
+ jobId;
1429
+ accountId;
1430
+ threadId;
1431
+ idempotencyKey;
1432
+ state;
1433
+ reason;
1434
+ constructor(job, accountId, threadId2, key, reason) {
1435
+ super(`Native media job ${job.id} requires inspection (${job.state}); retain the original job and idempotency key and do not automatically resend`);
1436
+ this.name = "NativeMediaJobInspectionError";
1437
+ this.jobId = job.id;
1438
+ this.accountId = accountId;
1439
+ this.threadId = threadId2;
1440
+ this.idempotencyKey = key;
1441
+ this.state = job.state;
1442
+ this.reason = reason;
1443
+ }
1444
+ };
1195
1445
  function mediaSendResult(job) {
1196
1446
  return job.result && typeof job.result === "object" && !Array.isArray(job.result) ? job.result : {};
1197
1447
  }
@@ -1207,11 +1457,11 @@ function mediaSendReceiptIds(job, audioAsVoice) {
1207
1457
  async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial, audioAsVoice, timeoutMs = 1e4) {
1208
1458
  if (initial.accountId !== accountId || initial.threadId !== threadId2 || initial.kind !== "send") throw Error("Native media receipt job identity mismatch; inspect the submitted job and do not resend");
1209
1459
  const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 1e4));
1210
- let job = initial, delay = 100;
1460
+ let job = initial, delay2 = 100;
1211
1461
  while (successfulSendJobs.has(job.state) && !mediaSendReceiptIds(job, audioAsVoice) && Date.now() < deadline) {
1212
1462
  const result = mediaSendResult(job);
1213
1463
  if (["unknown", "not_applicable"].includes(String(result.profileProjection)) || audioAsVoice && Array.isArray(result.chunks) && result.chunks.some((chunk) => !chunk || chunk.state !== "accepted")) break;
1214
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1464
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1215
1465
  const remaining = deadline - Date.now();
1216
1466
  if (remaining <= 0) break;
1217
1467
  let next;
@@ -1222,27 +1472,46 @@ async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial
1222
1472
  }
1223
1473
  if (next.id !== initial.id || next.accountId !== accountId || next.threadId !== threadId2 || next.kind !== "send" || next.sourceId !== initial.sourceId || initial.serverMessageId != null && next.serverMessageId !== initial.serverMessageId) break;
1224
1474
  job = next;
1225
- delay = Math.min(delay * 2, 1e3);
1475
+ delay2 = Math.min(delay2 * 2, 1e3);
1226
1476
  }
1227
1477
  return job;
1228
1478
  }
1229
1479
  async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1480
+ return waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, (job, pollSignal) => client.job(accountId, job.id, pollSignal));
1481
+ }
1482
+ async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, readStatus) {
1230
1483
  const deadline = Date.now() + timeoutMs;
1231
1484
  let job = initial;
1232
- let delay = 250;
1485
+ let delay2 = 250;
1233
1486
  while (!terminalJobs.has(job.state) && Date.now() < deadline) {
1234
1487
  signal?.throwIfAborted();
1235
1488
  assertCurrent?.();
1236
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1489
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1237
1490
  const remaining = deadline - Date.now();
1238
1491
  if (remaining <= 0) break;
1239
1492
  signal?.throwIfAborted();
1240
1493
  assertCurrent?.();
1241
- job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1242
- delay = Math.min(delay * 2, 1e3);
1494
+ const next = await readStatus(job, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1495
+ if (!next) break;
1496
+ job = next;
1497
+ delay2 = Math.min(delay2 * 2, 1e3);
1243
1498
  }
1244
1499
  return job;
1245
1500
  }
1501
+ async function incomingMediaJobStatus(client, accountId, jobId, assetId, signal, assertCurrent) {
1502
+ let job;
1503
+ assertCurrent();
1504
+ try {
1505
+ job = await client.job(accountId, jobId, signal);
1506
+ } catch (error) {
1507
+ assertCurrent();
1508
+ 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;
1509
+ throw error;
1510
+ }
1511
+ assertCurrent();
1512
+ 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");
1513
+ return job;
1514
+ }
1246
1515
  function select(assets, type, quality = "best") {
1247
1516
  const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
1248
1517
  const rank = (asset) => {
@@ -1301,83 +1570,37 @@ ${prepared.body}`,
1301
1570
  };
1302
1571
  }
1303
1572
  async function prepareNativeQuote(params) {
1304
- const { client, account, message } = params;
1305
- const guard = () => {
1573
+ const { message } = params;
1574
+ const guard2 = () => {
1306
1575
  params.signal?.throwIfAborted();
1307
1576
  params.assertCurrent?.();
1308
1577
  };
1309
- guard();
1310
- const comment = (message.body ?? "").slice(0, 16384);
1311
- const unavailable = (state) => ({ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state}; original lookup is confined to this chat]`, pending: false, retryable: state === "unavailable", quote: { state } });
1312
- await params.beforeMediaAccess?.();
1313
- guard();
1314
- let resolution;
1315
- try {
1316
- resolution = await client.quotedMessage(account.native.accountId, message.id);
1317
- } catch (error) {
1318
- guard();
1319
- if (!(error instanceof NativeApiError)) throw error;
1320
- if (error.code === "message_recalled") return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1321
- return terminalIdentityErrors.has(error.code) ? { ...unavailable("unavailable"), retryable: false, reason: error.code } : unavailable("unavailable");
1322
- }
1323
- guard();
1324
- if (resolution.accountId !== message.accountId || resolution.messageId !== message.id || resolution.threadId !== message.threadId) throw Error("Native quote identity does not match its message");
1325
- if (resolution.state === "recalled" || resolution.message && nativeRecallInfo(resolution.message)) return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1326
- if (!message.quote) return unavailable("invalid");
1327
- if (resolution.state === "embedded") {
1328
- if (resolution.message != null || (message.quote.type === 1 ? typeof resolution.embeddedText !== "string" || resolution.embeddedText.length > 16384 : message.quote.type !== 3 || resolution.embeddedImage !== true)) throw Error("Invalid embedded quote resolution");
1329
- const author = { senderId: resolution.senderId ?? null, direction: null };
1330
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) return unavailable("not permitted");
1331
- guard();
1332
- const quote2 = { state: "embedded", senderId: resolution.senderId ?? void 0, type: message.quote.type };
1333
- const prefix2 = `${comment}${comment ? "\n\n" : ""}[Quoted ${message.quote.type === 3 ? "image" : "text"} from ${resolution.senderId ?? "unknown sender"}; embedded in quote, original not stored]
1334
- `;
1335
- if (message.quote.type === 1) return { body: `${prefix2}${resolution.embeddedText}
1336
- [/Quoted message]`, pending: false, quote: quote2 };
1337
- const beforeMediaAccess2 = async () => {
1338
- await params.beforeMediaAccess?.();
1339
- guard();
1340
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1341
- guard();
1342
- };
1343
- const prepared2 = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0 }, beforeMediaAccess: beforeMediaAccess2 });
1344
- return {
1345
- ...prepared2,
1346
- body: `${prefix2}${prepared2.body}
1347
- [/Quoted message]`,
1348
- pending: false,
1349
- quote: quote2,
1350
- ...prepared2.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1351
- };
1352
- }
1353
- if (resolution.state !== "resolved") {
1354
- if (!["missing", "invalid", "unsupported", "ambiguous"].includes(resolution.state) || resolution.message != null) throw Error("Invalid native quote resolution");
1355
- return unavailable(resolution.state);
1356
- }
1357
- const original = resolution.message;
1358
- if (!original || original.accountId !== message.accountId || original.threadId !== message.threadId || original.id === message.id || original.serverId !== message.quote.serverId || original.type !== message.quote.type || ![1, 3].includes(original.type)) throw Error("Native quoted original crosses its message boundary");
1359
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) return unavailable("not permitted");
1360
- guard();
1361
- const quote = { state: "resolved", messageId: original.id, senderId: original.senderId ?? void 0, type: original.type };
1362
- const prefix = `${comment}${comment ? "\n\n" : ""}[Quoted ${original.type === 3 ? "image" : "text"} from ${original.senderId ?? "unknown sender"}; message ${original.id}]
1363
- `;
1364
- if (original.type === 1) return { body: `${prefix}${(original.body ?? "").slice(0, 16384)}
1365
- [/Quoted message]`, pending: false, quote };
1578
+ guard2();
1579
+ const author = { senderId: message.quote?.senderId ?? null, direction: message.quote?.direction ?? null };
1580
+ const permitted = !message.quote || !params.allowQuotedMessage || await params.allowQuotedMessage(author);
1581
+ guard2();
1582
+ const rendered = renderEmbeddedQuote(message, permitted);
1583
+ if (!rendered.image) return {
1584
+ body: rendered.body,
1585
+ quote: rendered.quote,
1586
+ pending: false,
1587
+ retryable: false,
1588
+ ...rendered.quote.state === "recalled" ? { reason: "message_recalled" } : {}
1589
+ };
1366
1590
  const beforeMediaAccess = async () => {
1367
1591
  await params.beforeMediaAccess?.();
1368
- guard();
1369
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
1370
- guard();
1592
+ guard2();
1593
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1594
+ guard2();
1371
1595
  };
1372
- const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
1596
+ const prepared = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0, link: void 0 }, beforeMediaAccess });
1597
+ if (prepared.reason === "message_recalled" || terminalIdentityErrors.has(prepared.reason ?? "")) return prepared;
1373
1598
  return {
1374
1599
  ...prepared,
1375
- body: `${prefix}${prepared.body}
1376
- [/Quoted message]`,
1600
+ body: renderEmbeddedQuote(message, true, prepared.body).body,
1601
+ quote: rendered.quote,
1377
1602
  pending: false,
1378
- quote,
1379
- ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {},
1380
- suppliedAssets: prepared.suppliedAssets?.map((asset) => ({ ...asset, sourceMessageId: original.id }))
1603
+ ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1381
1604
  };
1382
1605
  }
1383
1606
  async function prepareNativeAttachment(params) {
@@ -1402,16 +1625,16 @@ async function prepareNativeAttachment(params) {
1402
1625
  async function prepareNativeAttachmentInput(params) {
1403
1626
  const { client, account, message, core } = params;
1404
1627
  const attachmentKind = kind(message.type);
1405
- const guard = () => {
1628
+ const guard2 = () => {
1406
1629
  params.signal?.throwIfAborted();
1407
1630
  params.assertCurrent?.();
1408
1631
  };
1409
- guard();
1632
+ guard2();
1410
1633
  if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1411
1634
  const authorize = async () => {
1412
- guard();
1635
+ guard2();
1413
1636
  await params.beforeMediaAccess?.();
1414
- guard();
1637
+ guard2();
1415
1638
  };
1416
1639
  if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
1417
1640
  if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
@@ -1419,11 +1642,14 @@ async function prepareNativeAttachmentInput(params) {
1419
1642
  if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
1420
1643
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1421
1644
  const accountId = account.native.accountId;
1422
- const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
1645
+ const maxBytes = Math.min(
1646
+ account.mediaMaxMb * 1024 * 1024,
1647
+ attachmentKind === "file" ? Math.max(NATIVE_MAX_SOURCE_BYTES, 128 * 1024 * 1024) : 128 * 1024 * 1024
1648
+ );
1423
1649
  const scopedAssets = async () => {
1424
1650
  await authorize();
1425
1651
  const items = (await client.mediaAssets(accountId, message.id)).items;
1426
- guard();
1652
+ guard2();
1427
1653
  if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
1428
1654
  return items;
1429
1655
  };
@@ -1445,9 +1671,9 @@ async function prepareNativeAttachmentInput(params) {
1445
1671
  let transferError;
1446
1672
  if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1447
1673
  await authorize();
1448
- job = await client.job(accountId, initialCandidate.jobId, params.signal);
1449
- 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");
1674
+ job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard2);
1675
+ guard2();
1676
+ if (!job) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: initialCandidate.jobId, retryable: true };
1451
1677
  assets = await scopedAssets();
1452
1678
  asset = select(assets, message.type, quality);
1453
1679
  if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
@@ -1457,11 +1683,17 @@ async function prepareNativeAttachmentInput(params) {
1457
1683
  await authorize();
1458
1684
  try {
1459
1685
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1460
- guard();
1461
- job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1462
- guard();
1686
+ guard2();
1687
+ job = await waitNativeMediaStatus(
1688
+ job,
1689
+ params.waitMs ?? 2e4,
1690
+ params.signal,
1691
+ guard2,
1692
+ (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard2)
1693
+ );
1694
+ guard2();
1463
1695
  } catch (error) {
1464
- guard();
1696
+ guard2();
1465
1697
  if (!(error instanceof NativeApiError) || terminalIdentityErrors.has(error.code)) throw error;
1466
1698
  if (error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1467
1699
  transferError = error;
@@ -1491,14 +1723,63 @@ async function prepareNativeAttachmentInput(params) {
1491
1723
  return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1492
1724
  }
1493
1725
  await authorize();
1726
+ if (attachmentKind === "file" && asset.byteLength != null && asset.byteLength > 128 * 1024 * 1024) {
1727
+ const content2 = await client.mediaContentStream(accountId, asset.id, { maxBytes, signal: params.signal });
1728
+ const mime2 = content2.contentType.split(";", 1)[0].trim().toLowerCase();
1729
+ if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime2)) {
1730
+ await content2.cancel();
1731
+ throw Error("Native attachment MIME type is unsupported");
1732
+ }
1733
+ let saved2;
1734
+ try {
1735
+ saved2 = await saveVerifiedManagedAttachmentStream({
1736
+ stream: content2.stream,
1737
+ mime: mime2,
1738
+ filename,
1739
+ maxBytes,
1740
+ expectedBytes: asset.byteLength,
1741
+ expectedSha256: asset.sha256 ?? "",
1742
+ assertCurrent: guard2,
1743
+ saveMediaStream: params.saveMediaStream ?? saveMediaStream
1744
+ });
1745
+ } finally {
1746
+ await content2.cancel();
1747
+ }
1748
+ await authorize();
1749
+ const attachment2 = { kind: "file", filename, status: "ready", path: saved2.path, mime: mime2 };
1750
+ return {
1751
+ body: renderAttachmentBody("", attachment2),
1752
+ attachment: attachment2,
1753
+ mediaMime: mime2,
1754
+ pending: false,
1755
+ jobId: job?.id,
1756
+ retryable: false,
1757
+ variant: asset.actualVariant ?? asset.variant,
1758
+ suppliedAssets: [{
1759
+ id: asset.id,
1760
+ contextRole: "trigger",
1761
+ role: "file_reference",
1762
+ kind: "file",
1763
+ path: saved2.path,
1764
+ mime: mime2,
1765
+ sha256: saved2.sha256,
1766
+ byteLength: saved2.size,
1767
+ runtimeMedia: false,
1768
+ sourceSha256: saved2.sha256,
1769
+ sourceByteLength: saved2.size,
1770
+ sourceMime: mime2,
1771
+ sourcePath: saved2.path
1772
+ }]
1773
+ };
1774
+ }
1494
1775
  const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1495
- guard();
1776
+ guard2();
1496
1777
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
1497
1778
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
1498
1779
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
1499
1780
  if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
1500
1781
  await authorize();
1501
- const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1782
+ const saved = await saveManagedAttachmentBytes({ bytes: content.bytes, mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1502
1783
  const actual = asset.actualVariant ?? asset.variant;
1503
1784
  const attachment = {
1504
1785
  kind: attachmentKind,
@@ -1518,7 +1799,7 @@ async function prepareNativeAttachmentInput(params) {
1518
1799
  if (!preview || preview.sourceSha256 !== hash || !/^[a-f0-9]{64}$/.test(preview.sha256) || !Number.isInteger(preview.byteLength) || preview.byteLength <= 0 || preview.byteLength > 16 * 1024 * 1024 || !["image/jpeg", "image/png"].includes(preview.contentType)) throw Error("Native WXGF preview metadata did not verify");
1519
1800
  await authorize();
1520
1801
  const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
1521
- guard();
1802
+ guard2();
1522
1803
  const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
1523
1804
  if (derived.sourceSha256 !== hash || derivedMime !== preview.contentType || derived.bytes.length !== preview.byteLength || createHash4("sha256").update(derived.bytes).digest("hex") !== preview.sha256) throw Error("Native WXGF preview integrity did not verify");
1524
1805
  imageBytes = Buffer.from(derived.bytes);
@@ -1543,7 +1824,7 @@ async function prepareNativeAttachmentInput(params) {
1543
1824
  [Model image: prepared still preview; original retained separately]`;
1544
1825
  }
1545
1826
  }
1546
- guard();
1827
+ guard2();
1547
1828
  return {
1548
1829
  body,
1549
1830
  attachment,
@@ -1570,78 +1851,114 @@ async function prepareNativeAttachmentInput(params) {
1570
1851
  pending: false
1571
1852
  };
1572
1853
  }
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);
1854
+ function validateNativeMediaSendOptions(options, audioAsVoice = false) {
1855
+ if (options.forceDocument != null && typeof options.forceDocument !== "boolean") throw new Error("Invalid WeChat document option");
1856
+ if (options.videoQuality != null && !["normal", "original"].includes(options.videoQuality)) throw new Error("Invalid WeChat video quality");
1857
+ if (options.forceDocument && (audioAsVoice || options.videoQuality != null)) throw new Error("Document sending cannot also request voice or video quality");
1858
+ }
1859
+ function outboundMediaKind(mime, audioAsVoice, options) {
1860
+ if (options.forceDocument) return "file";
1861
+ if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1862
+ const selected = audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file";
1863
+ if (options.videoQuality != null && selected !== "video") throw new Error("Video quality requires a video attachment");
1864
+ return selected;
1865
+ }
1866
+ function outboundSourceLimit(account, selectedKind) {
1867
+ 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);
1868
+ }
1869
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend, options = {}) {
1870
+ validateNativeMediaSendOptions(options, audioAsVoice);
1871
+ await beforeSend?.();
1872
+ let filename, mime, selectedKind, sourceBytes, source;
1873
+ let temporary;
1874
+ const accountId = account.native.accountId;
1875
+ try {
1876
+ let blob;
1877
+ if (/^https?:/.test(mediaUrl)) {
1878
+ const response = await fetch(mediaUrl, { redirect: "error", signal: AbortSignal.timeout(60 * 6e4) });
1879
+ if (!response.ok) throw new Error(`Native outbound source unavailable (${response.status})`);
1880
+ filename = basename(new URL(mediaUrl).pathname) || "attachment";
1881
+ mime = response.headers.get("content-type")?.split(";")[0].trim().toLowerCase() ?? "application/octet-stream";
1882
+ try {
1883
+ selectedKind = outboundMediaKind(mime, audioAsVoice, options);
1884
+ } catch (error) {
1885
+ await response.body?.cancel();
1886
+ throw error;
1597
1887
  }
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));
1888
+ const max = outboundSourceLimit(account, selectedKind);
1889
+ const length = response.headers.get("content-length");
1890
+ if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
1891
+ await response.body?.cancel();
1892
+ throw new Error("Native outbound source exceeds configured size");
1627
1893
  }
1628
- bytes = Buffer.concat(chunks2, size);
1629
- } finally {
1630
- await file.close();
1894
+ const reader = response.body?.getReader();
1895
+ if (!reader) throw new Error("Native outbound source has no bytes");
1896
+ let path, file, size = 0;
1897
+ try {
1898
+ temporary = await mkdtemp(join2(tmpdir(), "wechat-outbound-"));
1899
+ path = join2(temporary, "source");
1900
+ file = await open(path, "wx", 384);
1901
+ while (true) {
1902
+ const { value, done } = await reader.read();
1903
+ if (done) break;
1904
+ size += value.byteLength;
1905
+ if (size > max) throw new Error("Native outbound source exceeds configured size");
1906
+ await file.writeFile(value);
1907
+ }
1908
+ if (!size || length && size !== Number(length)) throw new Error("Native outbound source length mismatch");
1909
+ } finally {
1910
+ await reader.cancel().catch(() => {
1911
+ });
1912
+ reader.releaseLock();
1913
+ await file?.close();
1914
+ }
1915
+ blob = await openAsBlob(path, { type: mime });
1916
+ } else {
1917
+ const path = mediaUrl.startsWith("file:") ? new URL(mediaUrl) : mediaUrl;
1918
+ filename = basename(path instanceof URL ? path.pathname : path);
1919
+ const extension = extname(filename).toLowerCase();
1920
+ 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";
1921
+ selectedKind = outboundMediaKind(mime, audioAsVoice, options);
1922
+ const max = outboundSourceLimit(account, selectedKind), file = await open(path, "r");
1923
+ let size;
1924
+ try {
1925
+ const stat = await file.stat();
1926
+ if (!stat.isFile() || !stat.size || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1927
+ size = stat.size;
1928
+ } finally {
1929
+ await file.close();
1930
+ }
1931
+ blob = await openAsBlob(path, { type: mime });
1932
+ if (blob.size !== size) throw new Error("Native outbound source changed before staging");
1631
1933
  }
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";
1934
+ sourceBytes = blob.size;
1935
+ if (sourceBytes > outboundSourceLimit(account, selectedKind)) throw new Error("Native outbound source exceeds selected media kind size");
1936
+ await beforeSend?.();
1937
+ source = await client.upload(accountId, blob, filename, mime);
1938
+ } finally {
1939
+ if (temporary) await rm(temporary, { recursive: true, force: true });
1635
1940
  }
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 };
1941
+ const input = {
1942
+ threadId: threadId2,
1943
+ kind: selectedKind,
1944
+ sourceId: source.id,
1945
+ standardSourceId: null,
1946
+ thumbnailSourceId: null,
1947
+ playSeconds: null,
1948
+ durationMs: null,
1949
+ ...selectedKind === "video" ? { videoQuality: options.videoQuality ?? "normal" } : {}
1950
+ };
1641
1951
  await beforeSend?.();
1642
- let job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1952
+ const waitMs = audioAsVoice ? 25 * 6e4 : selectedKind === "video" || selectedKind === "file" && sourceBytes > NATIVE_MAX_ORDINARY_SOURCE_BYTES ? 60 * 6e4 : 65e3;
1953
+ const submitted = await client.sendMedia(accountId, input, key);
1954
+ let job;
1955
+ try {
1956
+ job = await waitNativeMediaJob(client, accountId, submitted, waitMs);
1957
+ } catch {
1958
+ throw new NativeMediaJobInspectionError(submitted, accountId, threadId2, key, "status_unavailable");
1959
+ }
1643
1960
  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`);
1961
+ if (!successfulSendJobs.has(job.state)) throw new NativeMediaJobInspectionError(job, accountId, threadId2, key, "job_not_completed");
1645
1962
  const result = mediaSendResult(job);
1646
1963
  const chunks = Array.isArray(result.chunks) ? result.chunks.filter((chunk) => chunk && typeof chunk === "object") : [];
1647
1964
  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 +1966,16 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1649
1966
  }
1650
1967
 
1651
1968
  // src/outbound-media.ts
1652
- async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
1969
+ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey, options = {}, beforeSend) {
1970
+ validateNativeMediaSendOptions(options, audioAsVoice);
1971
+ if (!mediaUrl && (options.forceDocument || options.videoQuality != null)) throw new Error("WeChat media options require an attachment");
1653
1972
  const account = resolveWeChatAccount(cfg);
1654
1973
  if (!account?.native) throw new Error("No native daemon configured");
1655
1974
  const { client } = await ensureNativeHost(account.native);
1656
1975
  const key = idempotencyKey ?? randomUUID2();
1657
1976
  if (!mediaUrl) {
1658
1977
  if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
1978
+ await beforeSend?.();
1659
1979
  const send = await submitNativeText(client, account.native.accountId, to, text3, key);
1660
1980
  return nativeOutboundDelivery(
1661
1981
  client,
@@ -1665,26 +1985,116 @@ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, i
1665
1985
  { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
1666
1986
  );
1667
1987
  }
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"));
1988
+ const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key, beforeSend, options);
1989
+ if (text3) {
1990
+ await beforeSend?.();
1991
+ await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
1992
+ }
1670
1993
  return result;
1671
1994
  }
1672
1995
 
1996
+ // src/outbound-options.ts
1997
+ function nativePayloadMediaOptions(payload) {
1998
+ const value = payload.channelData?.["agent-wechat"];
1999
+ if (value == null) return {};
2000
+ 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");
2001
+ return { videoQuality: value.videoQuality };
2002
+ }
2003
+ var wechatMediaActions = {
2004
+ describeMessageTool: () => ({ actions: ["send"], schema: { actions: ["send"], visibility: "all-configured", properties: {
2005
+ channelData: { type: "object", description: "WeChat per-send media options. Use agent-wechat.videoQuality for a single video attachment.", additionalProperties: true, properties: {
2006
+ "agent-wechat": { type: "object", additionalProperties: false, required: ["videoQuality"], properties: {
2007
+ videoQuality: { type: "string", enum: ["normal", "original"], description: "WeChat video mode: normal prepares playback; original also retains the unchanged source video. Cannot combine with forceDocument." }
2008
+ } }
2009
+ } }
2010
+ } } }),
2011
+ prepareSendPayload: ({ ctx, payload }) => {
2012
+ const options = nativePayloadMediaOptions(payload);
2013
+ if (options.videoQuality == null) return void 0;
2014
+ validateNativeMediaSendOptions({ ...options, forceDocument: ctx.params.forceDocument ?? ctx.params.asDocument }, payload.audioAsVoice);
2015
+ return payload;
2016
+ }
2017
+ };
2018
+ var NativeMediaPayloadInspectionError = class extends Error {
2019
+ retryable = false;
2020
+ code = "native_media_payload_inspection_required";
2021
+ deliveries;
2022
+ pendingJob;
2023
+ constructor(deliveries, pendingJob) {
2024
+ super("WeChat attachment batch requires inspection; retain completed receipts and do not automatically resend");
2025
+ this.name = "NativeMediaPayloadInspectionError";
2026
+ this.deliveries = deliveries;
2027
+ this.pendingJob = pendingJob;
2028
+ }
2029
+ };
2030
+ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2031
+ const key = (ctx, index = 0) => ctx.deliveryQueueId ? createHash6("sha256").update(JSON.stringify(["wechat-media", ctx.deliveryQueueId, ctx.deliveryPartIndex ?? 0, index])).digest("hex") : void 0;
2032
+ const guard2 = (ctx) => async () => {
2033
+ ctx.signal?.throwIfAborted();
2034
+ await ctx.onPlatformSendDispatch?.();
2035
+ ctx.signal?.throwIfAborted();
2036
+ ctx.assertDirectAdapterHandoff?.();
2037
+ };
2038
+ return {
2039
+ sendMedia: async (ctx) => ({
2040
+ channel: "agent-wechat",
2041
+ ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard2(ctx))
2042
+ }),
2043
+ sendPayload: async (ctx) => {
2044
+ const options = { ...nativePayloadMediaOptions(ctx.payload), forceDocument: ctx.forceDocument };
2045
+ const audioAsVoice = ctx.payload.audioAsVoice ?? ctx.audioAsVoice;
2046
+ validateNativeMediaSendOptions(options, audioAsVoice);
2047
+ const urls = [...new Set([...ctx.payload.mediaUrls ?? [], ctx.payload.mediaUrl ?? ctx.mediaUrl].filter((url2) => typeof url2 === "string" && !!url2))];
2048
+ if (urls.length > 16 || options.videoQuality != null && urls.length !== 1) throw Error("WeChat video quality requires one attachment per send");
2049
+ const deliveries = [];
2050
+ for (const [index, mediaUrl] of (urls.length ? urls : [void 0]).entries()) {
2051
+ let delivered;
2052
+ try {
2053
+ delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard2(ctx));
2054
+ } catch (error) {
2055
+ if (!deliveries.length) throw error;
2056
+ throw new NativeMediaPayloadInspectionError(deliveries, error instanceof NativeMediaJobInspectionError ? { jobId: error.jobId, accountId: error.accountId, threadId: error.threadId, idempotencyKey: error.idempotencyKey } : void 0);
2057
+ }
2058
+ deliveries.push(delivered);
2059
+ if (urls.length > 1) {
2060
+ try {
2061
+ await ctx.onDeliveryResult?.({ channel: "agent-wechat", ...delivered });
2062
+ } catch {
2063
+ throw new NativeMediaPayloadInspectionError(deliveries);
2064
+ }
2065
+ }
2066
+ if (delivered.meta.outcome === "unknown") break;
2067
+ }
2068
+ const first = deliveries[0];
2069
+ return {
2070
+ channel: "agent-wechat",
2071
+ ...first,
2072
+ ...deliveries.length > 1 ? { meta: {
2073
+ ...first.meta,
2074
+ parts: deliveries,
2075
+ retryable: false,
2076
+ ...deliveries.some((part) => part.meta.outcome === "unknown") ? { outcome: "unknown", receiptState: "needs_review" } : {}
2077
+ } } : {}
2078
+ };
2079
+ }
2080
+ };
2081
+ }
2082
+
1673
2083
  // src/native-monitor.ts
1674
- import { createHash as createHash8 } from "node:crypto";
2084
+ import { createHash as createHash9 } from "node:crypto";
1675
2085
  import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
1676
2086
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
1677
2087
 
1678
2088
  // src/native-consumer-state.ts
1679
- import { createHash as createHash6, randomUUID as randomUUID3 } from "node:crypto";
2089
+ import { createHash as createHash7, randomUUID as randomUUID3 } from "node:crypto";
1680
2090
  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";
2091
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3 } from "node:path";
1682
2092
  var NativeConsumerState = class {
1683
2093
  file;
1684
2094
  value;
1685
2095
  constructor(directory, serverId, accountId) {
1686
2096
  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`);
2097
+ this.file = join3(directory, `${createHash7("sha256").update(`${serverId}\0${accountId}`).digest("hex")}.json`);
1688
2098
  this.value = { schemaVersion: 1, serverId, accountId, cursor: "0", items: {} };
1689
2099
  if (existsSync(this.file)) {
1690
2100
  const stat = lstatSync(this.file);
@@ -1693,6 +2103,7 @@ var NativeConsumerState = class {
1693
2103
  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
2104
  for (const [id2, item] of Object.entries(parsed.items)) {
1695
2105
  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");
2106
+ nativeRecallInfo(item.message);
1696
2107
  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
2108
  if (item.revision != null && (typeof item.revision !== "string" || !item.revision || item.revision.length > 64)) throw Error("Invalid native custody revision; state was preserved");
1698
2109
  item.revision ??= randomUUID3();
@@ -1822,13 +2233,8 @@ var NativeConsumerState = class {
1822
2233
  }
1823
2234
  const key = nativeRecallIdentity(recall.threadId, recall.serverId);
1824
2235
  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
- };
2236
+ const remembered = mergeNativeRecall(this.value.recalls[key] ?? recall, recall);
2237
+ this.value.recalls[key] = remembered;
1832
2238
  const affected = /* @__PURE__ */ new Set();
1833
2239
  for (const item of this.items()) {
1834
2240
  if (item.message.threadId !== recall.threadId) continue;
@@ -1836,7 +2242,7 @@ var NativeConsumerState = class {
1836
2242
  const quote = item.message.quote?.serverId === recall.serverId;
1837
2243
  if (!target && !quote) continue;
1838
2244
  affected.add(item.message.id);
1839
- item.message = target ? recalledNativeMessage(item.message, recall) : { ...item.message, quote: void 0 };
2245
+ item.message = target ? recalledNativeMessage(item.message, remembered) : { ...item.message, quote: void 0 };
1840
2246
  if (target) item.groupContext = false;
1841
2247
  item.readPending = false;
1842
2248
  if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
@@ -1907,7 +2313,7 @@ var NativeConsumerState = class {
1907
2313
  });
1908
2314
  const revisions = items.map((item) => item.revision);
1909
2315
  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({
2316
+ const manifestHash = createHash7("sha256").update(JSON.stringify({
1911
2317
  accountId: this.value.accountId,
1912
2318
  messageIds: exact,
1913
2319
  dispatchManifests: items.map((item) => item.dispatchManifest ?? null)
@@ -1916,7 +2322,7 @@ var NativeConsumerState = class {
1916
2322
  const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
1917
2323
  if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
1918
2324
  this.mutate(() => {
1919
- const currentHash = createHash6("sha256").update(JSON.stringify({
2325
+ const currentHash = createHash7("sha256").update(JSON.stringify({
1920
2326
  accountId: this.value.accountId,
1921
2327
  messageIds: exact,
1922
2328
  dispatchManifests: exact.map((id2) => this.value.items[id2]?.dispatchManifest ?? null)
@@ -1935,11 +2341,11 @@ var NativeConsumerState = class {
1935
2341
  };
1936
2342
 
1937
2343
  // src/native-prepared-boundary.ts
1938
- import { createHash as createHash7, randomUUID as randomUUID4 } from "node:crypto";
2344
+ import { createHash as createHash8, randomUUID as randomUUID4 } from "node:crypto";
1939
2345
  import { basename as basename2, isAbsolute as isAbsolute3 } from "node:path";
1940
2346
  var pending = /* @__PURE__ */ new Map();
1941
2347
  var enabled = false;
1942
- var digest = (value) => createHash7("sha256").update(value).digest("hex");
2348
+ var digest = (value) => createHash8("sha256").update(value).digest("hex");
1943
2349
  function prune() {
1944
2350
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1945
2351
  }
@@ -1950,7 +2356,7 @@ function nativeDispatchManifest(params) {
1950
2356
  const history = params.history ?? [];
1951
2357
  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
2358
  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");
2359
+ 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
2360
  return Object.freeze({ ...asset });
1955
2361
  }));
1956
2362
  const messageSid = nativeSourceMessageSid(params.message);
@@ -2277,17 +2683,17 @@ async function recheckNativeInputs(client, state, messages) {
2277
2683
  throw new NativeInputInvalidated();
2278
2684
  }
2279
2685
  if (message.quote) {
2280
- const quote = await client.quotedMessage(message.accountId, message.id);
2281
- if (quote.accountId !== message.accountId || quote.threadId !== message.threadId || quote.messageId !== message.id) throw Error("Native quote identity changed");
2282
- if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) {
2686
+ const quote = current.quote;
2687
+ if (quote?.state === "recalled") {
2283
2688
  const affected = state.acceptRecall(state.cursor, {
2284
2689
  threadId: message.threadId,
2285
2690
  serverId: message.quote.serverId,
2286
- observedAt: quote.message && nativeRecallInfo(quote.message)?.observedAt || (/* @__PURE__ */ new Date()).toISOString()
2691
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
2287
2692
  });
2288
2693
  invalidateNativePreparedInputs(message.accountId, affected);
2289
2694
  throw new NativeInputInvalidated();
2290
2695
  }
2696
+ if (!quote || JSON.stringify(quote) !== JSON.stringify(message.quote)) throw Error("Native embedded quote changed; delivery held for review");
2291
2697
  }
2292
2698
  if (!state.isCurrent(message)) throw new NativeInputInvalidated();
2293
2699
  }
@@ -2444,7 +2850,7 @@ async function dispatchNativeMessageInput(params, revision) {
2444
2850
  });
2445
2851
  assertCurrent();
2446
2852
  state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
2447
- const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
2853
+ const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote && [1, 49].includes(preparedMedia.quote.type ?? 0) && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
2448
2854
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2449
2855
  const beforeSend = () => recheck([message, ...admitted]);
2450
2856
  let failed = false;
@@ -2463,12 +2869,12 @@ async function dispatchNativeMessageInput(params, revision) {
2463
2869
  if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
2464
2870
  if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
2465
2871
  for (const url2 of urls) {
2466
- const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2872
+ const key = createHash9("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2467
2873
  await sendNativeAttachment(client, account, message.threadId, url2, payload.audioAsVoice === true, key, beforeSend);
2468
2874
  }
2469
2875
  if (payload.text) {
2470
2876
  await beforeSend();
2471
- const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2877
+ const key = createHash9("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2472
2878
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
2473
2879
  }
2474
2880
  if (urls.length || payload.text) params.onDelivered?.();
@@ -2644,43 +3050,69 @@ async function startNativeMonitor(opts) {
2644
3050
 
2645
3051
  // src/native-login.ts
2646
3052
  import { randomUUID as randomUUID5 } from "node:crypto";
3053
+ import { setTimeout as delay } from "node:timers/promises";
2647
3054
  var operations = /* @__PURE__ */ new Map();
2648
3055
  var terminal = /* @__PURE__ */ new Set(["succeeded", "failed", "cancelled", "expired", "rejected", "needs_login"]);
2649
- async function nativeLoginStart(account, options = {}) {
3056
+ function guard(access2) {
3057
+ access2.signal?.throwIfAborted();
3058
+ access2.assertCurrent?.();
3059
+ }
3060
+ function validateOperation(operation, accountId, operationId) {
3061
+ if (operation.accountId !== accountId || operation.kind !== "login" || operationId && operation.id !== operationId) throw Error("Native login operation identity does not match its account");
3062
+ }
3063
+ async function nativeLoginStart(account, options = {}, access2 = {}) {
3064
+ guard(access2);
2650
3065
  const native = account.native;
2651
- const { client } = await ensureNativeHost(native);
3066
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3067
+ guard(access2);
3068
+ const configurationKey = nativeConfigurationKey(native);
2652
3069
  const existing = operations.get(account.accountId);
2653
- if (existing && existing.accountId === native.accountId && !options.force) {
2654
- existing.operation = await client.operation(native.accountId, existing.operation.id);
2655
- if (!terminal.has(existing.operation.state)) return nativeLoginWait(account.accountId, Math.min(options.timeoutMs ?? 3e4, 3e4), true);
2656
- }
3070
+ if (existing && existing.configurationKey === configurationKey) {
3071
+ const refreshed = await client.operation(native.accountId, existing.operation.id);
3072
+ guard(access2);
3073
+ validateOperation(refreshed, native.accountId, existing.operation.id);
3074
+ existing.operation = refreshed;
3075
+ existing.client = client;
3076
+ if (!terminal.has(existing.operation.state)) return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
3077
+ }
3078
+ guard(access2);
2657
3079
  const operation = await client.login(native.accountId, {
2658
3080
  allowQr: true,
2659
3081
  forceQr: options.force === true,
2660
3082
  timeoutSeconds: Math.max(10, Math.min(900, Math.ceil((options.timeoutMs ?? 3e5) / 1e3))),
2661
3083
  computerName: "agent-wechat"
2662
- });
2663
- operations.set(account.accountId, { client, accountId: native.accountId, operation });
2664
- return nativeLoginWait(account.accountId, Math.min(options.timeoutMs ?? 3e4, 3e4), true);
3084
+ }, randomUUID5());
3085
+ validateOperation(operation, native.accountId);
3086
+ operations.set(account.accountId, { client, accountId: native.accountId, configurationKey, operation });
3087
+ guard(access2);
3088
+ return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
2665
3089
  }
2666
- async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
2667
- const active2 = operations.get(accountId);
2668
- if (!active2) return { connected: false, message: "No native login operation; start login first" };
3090
+ async function nativeLoginWait(account, timeoutMs = 3e4, stopAtQr = false, access2 = {}) {
3091
+ guard(access2);
3092
+ const active2 = operations.get(account.accountId);
3093
+ if (!active2 || active2.configurationKey !== nativeConfigurationKey(account.native)) return { connected: false, state: "idle", message: "No native login operation; start login first" };
2669
3094
  const deadline = Date.now() + Math.max(1e3, Math.min(timeoutMs, 3e5));
2670
3095
  while (Date.now() < deadline) {
2671
- active2.operation = await active2.client.operation(active2.accountId, active2.operation.id);
3096
+ guard(access2);
3097
+ const refreshed = await active2.client.operation(active2.accountId, active2.operation.id);
3098
+ guard(access2);
3099
+ validateOperation(refreshed, active2.accountId, active2.operation.id);
3100
+ active2.operation = refreshed;
2672
3101
  const state = active2.operation.state;
2673
- if (state === "succeeded") return { connected: true, message: "Native login succeeded" };
2674
- if (terminal.has(state)) return { connected: false, message: `Native login ${state}` };
3102
+ const result = { connected: state === "succeeded", state, operationId: active2.operation.id };
3103
+ if (state === "succeeded") return { ...result, message: "Native login succeeded" };
3104
+ if (terminal.has(state)) return { ...result, message: `Native login ${state}` };
3105
+ if (stopAtQr && state === "awaiting_approval") return { ...result, message: "Approve native login on your phone" };
2675
3106
  if (stopAtQr && active2.operation.result?.qrAvailable === true) {
2676
3107
  const generation = active2.operation.result?.qrGeneration;
2677
3108
  const png = await active2.client.qr(active2.accountId, active2.operation.id, typeof generation === "number" ? generation : void 0);
2678
- return { connected: false, qrDataUrl: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, message: "Scan the white-background native QR and approve login" };
3109
+ guard(access2);
3110
+ return { ...result, qrDataUrl: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, message: "Scan the white-background native QR and approve login" };
2679
3111
  }
2680
- if (stopAtQr && state === "awaiting_approval") return { connected: false, message: "Approve native login on your phone" };
2681
- await new Promise((resolve) => setTimeout(resolve, 500));
3112
+ await delay(Math.min(500, Math.max(0, deadline - Date.now())), void 0, { signal: access2.signal });
2682
3113
  }
2683
- return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
3114
+ guard(access2);
3115
+ return { connected: false, state: active2.operation.state, operationId: active2.operation.id, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
2684
3116
  }
2685
3117
  async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
2686
3118
  const native = account.native;
@@ -2694,29 +3126,59 @@ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
2694
3126
  { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
2695
3127
  );
2696
3128
  }
2697
- function createNativeLoginTool(account) {
3129
+ function createNativeLoginTool(account, dependencies = {}) {
3130
+ const actions = ["status", "start", "wait", "disconnect", "logout", "forget"];
3131
+ const configurationKey = nativeConfigurationKey(account.native);
2698
3132
  return {
2699
3133
  label: "WeChat Login",
2700
3134
  name: "wechat_login",
2701
- description: "Inspect/start native login, disconnect retaining trust, or request durable logout. Logout commits local inhibition and reports its native server outcome separately; it does not promise saved-device revocation. Explicitly confirmed forget removes local authentication while retaining history. A QR is returned as an image when available.",
2702
- parameters: { type: "object", properties: { action: { type: "string", enum: ["status", "start", "disconnect", "logout", "forget"] }, force: { type: "boolean" }, confirm: { type: "boolean" } }, required: ["action"] },
2703
- execute: async (_id, params) => {
3135
+ catalogMode: "direct-only",
3136
+ description: "Check WeChat status, start or resume native login, or wait for the same login operation. Show the returned QR image to the user to scan and approve on their phone; do not generate a replacement QR. Repeated start reuses an active operation. For a new operation, force uses QR instead of saved trust. timeoutMs bounds the wait. Disconnect retains saved trust. Logout reports its durable native outcome; it does not promise saved-device revocation. Forget requires confirm=true and removes local authentication while retaining history.",
3137
+ parameters: { type: "object", additionalProperties: false, properties: {
3138
+ action: { type: "string", enum: actions },
3139
+ force: { type: "boolean", description: "With start, use QR instead of saved trust for a new login. An active login is still reused." },
3140
+ timeoutMs: { type: "integer", minimum: 1e3, maximum: 3e5, description: "Wait bound in milliseconds; defaults to 30000. Start returns as soon as a QR is available." },
3141
+ confirm: { type: "boolean", description: "Required true for forget." }
3142
+ }, required: ["action"] },
3143
+ execute: async (_id, params, signal) => {
3144
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw Error("Expected login arguments");
2704
3145
  const args = params;
2705
- const native = account.native;
2706
- const { client } = await ensureNativeHost(native);
2707
- if (args.action === "start") {
2708
- const result2 = await nativeLoginStart(account, { force: args.force });
3146
+ if (Object.keys(args).some((key) => !["action", "force", "timeoutMs", "confirm"].includes(key)) || !actions.includes(args.action) || args.force !== void 0 && typeof args.force !== "boolean" || args.confirm !== void 0 && typeof args.confirm !== "boolean" || args.timeoutMs !== void 0 && (!Number.isSafeInteger(args.timeoutMs) || args.timeoutMs < 1e3 || args.timeoutMs > 3e5)) throw Error("Invalid native login arguments");
3147
+ if (args.action === "forget" && args.confirm !== true) throw Error("Forgetting native credentials requires confirm=true");
3148
+ const access2 = { host: dependencies.host, signal, assertCurrent: () => {
3149
+ dependencies.context?.assertInvocationCurrent?.();
3150
+ if (!dependencies.context) return;
3151
+ const context = dependencies.context;
3152
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3153
+ const current = config && resolveWeChatAccount(config);
3154
+ if (!current?.enabled || !current.native || nativeConfigurationKey(current.native) !== configurationKey) throw Error("Native login configuration changed; invoke the tool again");
3155
+ } };
3156
+ guard(access2);
3157
+ if (args.action === "start" || args.action === "wait") {
3158
+ const result2 = args.action === "start" ? await nativeLoginStart(account, { force: args.force, timeoutMs: args.timeoutMs }, access2) : await nativeLoginWait(account, args.timeoutMs, false, access2);
2709
3159
  const content = [{ type: "text", text: result2.message }];
2710
3160
  if (result2.qrDataUrl) content.push({ type: "image", data: result2.qrDataUrl.split(",")[1], mimeType: "image/png" });
2711
- return { content, details: { connected: result2.connected } };
3161
+ return { content, details: { connected: result2.connected, state: result2.state, operationId: result2.operationId } };
2712
3162
  }
3163
+ const native = account.native;
3164
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3165
+ guard(access2);
2713
3166
  const result = args.action === "status" ? await client.account(native.accountId) : args.action === "disconnect" ? await client.disconnect(native.accountId) : args.action === "logout" ? await client.logout(native.accountId, randomUUID5()) : args.action === "forget" && args.confirm === true ? await client.forgetCredentials(native.accountId, randomUUID5(), true) : (() => {
2714
3167
  throw new Error("Unknown native login action");
2715
3168
  })();
3169
+ if (args.action !== "status") operations.delete(account.accountId);
3170
+ guard(access2);
2716
3171
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2717
3172
  }
2718
3173
  };
2719
3174
  }
3175
+ function registerNativeLoginTool(api) {
3176
+ api.registerTool({ contextVersion: 2, create: (context) => {
3177
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3178
+ const account = config && resolveWeChatAccount(config);
3179
+ return account?.enabled && account.native ? [createNativeLoginTool(account, { context })] : [];
3180
+ } }, { names: ["wechat_login"] });
3181
+ }
2720
3182
 
2721
3183
  // src/channel.ts
2722
3184
  async function sendWeChatText(cfg, to, text3) {
@@ -2724,6 +3186,7 @@ async function sendWeChatText(cfg, to, text3) {
2724
3186
  if (!account?.serverUrl) throw new Error("No native daemon configured");
2725
3187
  return sendNativeText(account, to, text3);
2726
3188
  }
3189
+ var mediaHandlers = createWeChatMediaHandlers();
2727
3190
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2728
3191
  id: "agent-wechat",
2729
3192
  capabilities: {
@@ -2736,10 +3199,7 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2736
3199
  channel: "agent-wechat",
2737
3200
  ...await sendWeChatText(cfg, to, text3)
2738
3201
  }),
2739
- sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2740
- channel: "agent-wechat",
2741
- ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2742
- })
3202
+ ...mediaHandlers
2743
3203
  }
2744
3204
  });
2745
3205
  var meta = {
@@ -2763,6 +3223,7 @@ var wechatPlugin = {
2763
3223
  tts: { voice: { synthesisTarget: "voice-note", transcodesAudio: true } }
2764
3224
  },
2765
3225
  reload: { configPrefixes: ["channels.agent-wechat"] },
3226
+ actions: wechatMediaActions,
2766
3227
  configSchema: {
2767
3228
  uiHints: { "native.token": { sensitive: true } },
2768
3229
  schema: {
@@ -2884,10 +3345,7 @@ var wechatPlugin = {
2884
3345
  channel: "agent-wechat",
2885
3346
  ...await sendWeChatText(cfg, to, text3)
2886
3347
  }),
2887
- sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2888
- channel: "agent-wechat",
2889
- ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2890
- })
3348
+ ...mediaHandlers
2891
3349
  },
2892
3350
  message: wechatMessageAdapter,
2893
3351
  gateway: {
@@ -2917,7 +3375,7 @@ var wechatPlugin = {
2917
3375
  loginWithQrWait: async ({ accountId, timeoutMs }) => {
2918
3376
  const account = resolveWeChatAccount(getWeChatRuntime().config.current(), accountId ?? void 0);
2919
3377
  if (!account?.native) return { connected: false, message: "No native daemon configured" };
2920
- return nativeLoginWait(account.accountId, timeoutMs);
3378
+ return nativeLoginWait(account, timeoutMs);
2921
3379
  }
2922
3380
  },
2923
3381
  auth: {
@@ -2943,10 +3401,6 @@ var wechatPlugin = {
2943
3401
  linked: runtime2?.linked ?? false
2944
3402
  })
2945
3403
  },
2946
- agentTools: (({ cfg }) => {
2947
- const account = resolveWeChatAccount(cfg);
2948
- return account?.native ? [createNativeLoginTool(account)] : [];
2949
- }),
2950
3404
  directory: {
2951
3405
  self: async ({ cfg }) => {
2952
3406
  const account = resolveWeChatAccount(cfg);
@@ -3054,7 +3508,7 @@ function createNativeFacade(config, dependencies = {}) {
3054
3508
  fail(res, 403, "facade_account_forbidden");
3055
3509
  return true;
3056
3510
  }
3057
- if (!["GET", "HEAD", "POST", "PATCH", "DELETE"].includes(req.method ?? "")) {
3511
+ if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(req.method ?? "")) {
3058
3512
  fail(res, 405, "method_not_allowed");
3059
3513
  return true;
3060
3514
  }
@@ -3067,19 +3521,23 @@ function createNativeFacade(config, dependencies = {}) {
3067
3521
  try {
3068
3522
  const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
3069
3523
  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);
3524
+ for (const name3 of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
3525
+ const value = req.headers[name3];
3526
+ if (typeof value === "string") headers.set(name3, value);
3073
3527
  }
3528
+ const upload = pieces.length === 4 && pieces[3] === "uploads" && req.method === "POST";
3529
+ const mediaContent = pieces.length === 6 && pieces[3] === "media" && pieces[5] === "content" && parsed.searchParams.get("representation") !== "preview";
3530
+ const requestLimit = upload ? NATIVE_MAX_SOURCE_BYTES + 64 * 1024 : MAX_BODY;
3531
+ const responseLimit = mediaContent ? Math.max(MAX_BODY, NATIVE_MAX_SOURCE_BYTES) : MAX_BODY;
3074
3532
  const length = req.headers["content-length"];
3075
- if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
3533
+ if (length && (!/^\d+$/.test(length) || Number(length) > requestLimit)) {
3076
3534
  fail(res, 413, "payload_too_large");
3077
3535
  return true;
3078
3536
  }
3079
3537
  let size = 0;
3080
3538
  const body = ["GET", "HEAD"].includes(req.method ?? "") ? void 0 : Readable.toWeb(req).pipeThrough(new NodeTransformStream({ transform(chunk, controller) {
3081
3539
  size += chunk.byteLength;
3082
- if (size > MAX_BODY) {
3540
+ if (size > requestLimit) {
3083
3541
  tooLarge = true;
3084
3542
  throw new Error("payload_too_large");
3085
3543
  }
@@ -3092,10 +3550,16 @@ function createNativeFacade(config, dependencies = {}) {
3092
3550
  headers.set("Authorization", `Bearer ${dependencies.token}`);
3093
3551
  response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
3094
3552
  }
3553
+ const responseLength = response.headers.get("content-length");
3554
+ if (responseLength && (!/^\d+$/.test(responseLength) || Number(responseLength) > responseLimit)) {
3555
+ await response.body?.cancel();
3556
+ fail(res, 502, "native_facade_unavailable");
3557
+ return true;
3558
+ }
3095
3559
  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);
3560
+ for (const name3 of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
3561
+ const value = response.headers.get(name3);
3562
+ if (value) res.setHeader(name3, value);
3099
3563
  }
3100
3564
  res.setHeader("x-content-type-options", "nosniff");
3101
3565
  const reader = response.body?.getReader();
@@ -3110,7 +3574,7 @@ function createNativeFacade(config, dependencies = {}) {
3110
3574
  const { value, done } = await reader.read();
3111
3575
  if (done) break;
3112
3576
  emitted += value.length;
3113
- if (!sse && emitted > MAX_BODY) throw new Error("upstream_response_too_large");
3577
+ if (!sse && emitted > responseLimit) throw new Error("upstream_response_too_large");
3114
3578
  if (!res.write(value)) await drain(res, abort.signal);
3115
3579
  }
3116
3580
  } finally {
@@ -3154,14 +3618,14 @@ function object(value) {
3154
3618
  if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
3155
3619
  return value;
3156
3620
  }
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}`);
3621
+ function string(value, name3, max = 4096) {
3622
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name3}`);
3159
3623
  return value.trim();
3160
3624
  }
3161
3625
  function threadId(value) {
3162
3626
  return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
3163
3627
  }
3164
- function messageResult(message, retrievalTool = "wechat_media") {
3628
+ function messageResult(message, retrievalTool = "wechat_media", quotePermitted = false) {
3165
3629
  if (nativeRecallInfo(message)) return {
3166
3630
  id: message.id,
3167
3631
  threadId: message.threadId,
@@ -3176,8 +3640,15 @@ function messageResult(message, retrievalTool = "wechat_media") {
3176
3640
  const isForward = message.type === 49 && message.appMessageType === 19;
3177
3641
  const isLink = message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link;
3178
3642
  const kind2 = isQuote ? "quote" : isForward ? "forwarded_history" : isLink ? "link_preview" : message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 && (message.appMessageType == null || message.appMessageType === 6) ? "file" : "unsupported";
3179
- const body = message.type === 1 || isForward || isLink ? message.body ?? "" : isQuote ? `${message.body ?? ""}
3180
- [Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
3643
+ const body = message.type === 1 || isForward || isLink ? message.body ?? "" : isQuote ? renderEmbeddedQuote(message, quotePermitted).body : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
3644
+ const quote = message.quote && {
3645
+ serverId: message.quote.serverId,
3646
+ type: message.quote.type,
3647
+ state: quotePermitted ? message.quote.state : "not permitted",
3648
+ senderId: message.quote.senderId,
3649
+ appMessageType: message.quote.appMessageType,
3650
+ embeddedImage: quotePermitted ? message.quote.embeddedImage : void 0
3651
+ };
3181
3652
  return {
3182
3653
  id: message.id,
3183
3654
  threadId: message.threadId,
@@ -3186,7 +3657,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
3186
3657
  direction: message.direction,
3187
3658
  type: message.type,
3188
3659
  appMessageType: message.appMessageType,
3189
- quote: message.quote,
3660
+ quote,
3190
3661
  link: isLink ? message.link : void 0,
3191
3662
  createdAt: message.createdAt,
3192
3663
  body: body.slice(0, 4e3),
@@ -3227,9 +3698,9 @@ function createLookupTools(context, dependencies, archive) {
3227
3698
  if (!currentThread) return [];
3228
3699
  }
3229
3700
  const selectedId = selected.native.accountId;
3230
- const tool = (name2, action, description, properties, required = []) => ({
3231
- name: name2,
3232
- label: name2.replaceAll("_", " "),
3701
+ const tool = (name3, action, description, properties, required = []) => ({
3702
+ name: name3,
3703
+ label: name3.replaceAll("_", " "),
3233
3704
  description,
3234
3705
  // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
3235
3706
  // public direct-only surface preserves actual image content for vision.
@@ -3237,7 +3708,7 @@ function createLookupTools(context, dependencies, archive) {
3237
3708
  parameters: { type: "object", additionalProperties: false, properties, required },
3238
3709
  execute: async (_callId, input, signal) => {
3239
3710
  const args = object(input);
3240
- const guard = () => {
3711
+ const guard2 = () => {
3241
3712
  signal?.throwIfAborted();
3242
3713
  context.assertInvocationCurrent?.();
3243
3714
  const cfg2 = currentConfig();
@@ -3245,7 +3716,7 @@ function createLookupTools(context, dependencies, archive) {
3245
3716
  if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId || nativeConfigurationKey(account2.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
3246
3717
  return { cfg: cfg2, account: account2, native: account2.native };
3247
3718
  };
3248
- const { cfg, account, native } = guard();
3719
+ const { cfg, account, native } = guard2();
3249
3720
  if (Object.keys(args).some((key) => !Object.hasOwn(properties, key))) throw Error(archive ? "Unknown archive lookup argument" : "Unknown lookup argument; WeChat history is restricted to the current conversation");
3250
3721
  const limit = args.limit ?? 20;
3251
3722
  if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
@@ -3262,7 +3733,7 @@ function createLookupTools(context, dependencies, archive) {
3262
3733
  const core = dependencies.core ?? getWeChatRuntime();
3263
3734
  const allowed = async (sender) => {
3264
3735
  if (archive) return true;
3265
- const { cfg: cfg2, account: account2 } = guard();
3736
+ const { cfg: cfg2, account: account2 } = guard2();
3266
3737
  const result2 = await resolveWeChatIngress({
3267
3738
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
3268
3739
  cfg: cfg2,
@@ -3276,10 +3747,15 @@ function createLookupTools(context, dependencies, archive) {
3276
3747
  return result2.ingress.admission === "dispatch";
3277
3748
  };
3278
3749
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3279
- guard();
3750
+ guard2();
3280
3751
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3281
- guard();
3752
+ guard2();
3282
3753
  const accountId = native.accountId;
3754
+ const visibleMessage = async (message) => messageResult(
3755
+ message,
3756
+ archive ? "wechat_archive_media" : "wechat_media",
3757
+ !message.quote || archive || message.quote.direction === "outgoing" || !!message.quote.senderId && await allowed(message.quote.senderId)
3758
+ );
3283
3759
  let result;
3284
3760
  if (action === "threads") {
3285
3761
  const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
@@ -3292,21 +3768,20 @@ function createLookupTools(context, dependencies, archive) {
3292
3768
  result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
3293
3769
  } else if (action === "message" || action === "media") {
3294
3770
  const message = await client.message(accountId, messageId);
3295
- guard();
3771
+ guard2();
3296
3772
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3297
3773
  if (message.id !== messageId || message.accountId !== accountId || !archive && message.threadId !== conversation || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("Message is outside the permitted conversation or sender policy");
3298
- result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
3774
+ if (action === "message") result = await visibleMessage(message);
3299
3775
  if (action === "media") {
3300
3776
  const checkRecall = async () => {
3301
- guard();
3777
+ guard2();
3302
3778
  const latest = await client.message(accountId, messageId);
3303
- guard();
3779
+ guard2();
3304
3780
  if (latest.id !== message.id || latest.accountId !== accountId || latest.threadId !== message.threadId || latest.serverId !== message.serverId) throw Error("Native media message identity changed");
3305
3781
  if (nativeRecallInfo(latest)) throw new NativeApiError(410, "message_recalled", false);
3306
3782
  if (message.quote) {
3307
- const quote = await client.quotedMessage(accountId, message.id);
3308
- guard();
3309
- if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) throw new NativeApiError(410, "message_recalled", false);
3783
+ if (latest.quote?.state === "recalled") throw new NativeApiError(410, "message_recalled", false);
3784
+ if (!latest.quote || JSON.stringify(latest.quote) !== JSON.stringify(message.quote)) throw new NativeApiError(409, "media_source_changed", false);
3310
3785
  }
3311
3786
  };
3312
3787
  let prepared;
@@ -3321,7 +3796,7 @@ function createLookupTools(context, dependencies, archive) {
3321
3796
  download: args.download !== false,
3322
3797
  waitMs: 2e3,
3323
3798
  signal,
3324
- assertCurrent: guard,
3799
+ assertCurrent: guard2,
3325
3800
  allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
3326
3801
  beforeMediaAccess: async () => {
3327
3802
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
@@ -3329,7 +3804,7 @@ function createLookupTools(context, dependencies, archive) {
3329
3804
  }
3330
3805
  });
3331
3806
  } catch (error) {
3332
- guard();
3807
+ guard2();
3333
3808
  if (!(error instanceof NativeApiError)) throw error;
3334
3809
  const details2 = {
3335
3810
  messageId: message.id,
@@ -3340,7 +3815,7 @@ function createLookupTools(context, dependencies, archive) {
3340
3815
  };
3341
3816
  return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
3342
3817
  }
3343
- guard();
3818
+ guard2();
3344
3819
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
3345
3820
  const details = {
3346
3821
  messageId: message.id,
@@ -3357,7 +3832,7 @@ function createLookupTools(context, dependencies, archive) {
3357
3832
  };
3358
3833
  const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
3359
3834
  const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
3360
- guard();
3835
+ guard2();
3361
3836
  try {
3362
3837
  await checkRecall();
3363
3838
  } catch (error) {
@@ -3374,7 +3849,7 @@ function createLookupTools(context, dependencies, archive) {
3374
3849
  for (const message of page.items) {
3375
3850
  if (isNativeTombstone(message)) continue;
3376
3851
  if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
3377
- if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
3852
+ if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(await visibleMessage(message));
3378
3853
  }
3379
3854
  result = {
3380
3855
  ...page,
@@ -3384,7 +3859,7 @@ function createLookupTools(context, dependencies, archive) {
3384
3859
  note: archive ? "Account-wide local history may be partial. These reads do not mark threads read or download attachments." : "Local history may be partial. A filtered page can be empty with a nextCursor. These reads do not mark a conversation read or download attachments."
3385
3860
  };
3386
3861
  }
3387
- guard();
3862
+ guard2();
3388
3863
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3389
3864
  }
3390
3865
  });
@@ -3399,13 +3874,13 @@ function createLookupTools(context, dependencies, archive) {
3399
3874
  tool(archiveNames[1], "search", "Search locally stored messages across every thread in the configured WeChat account, including other senders. Optional threadId narrows the search. Follow nextCursor with the same query and thread selection; no remote backfill or read marking.", { query: querySchema, threadId: messageIdSchema, ...pageSchema }, ["query"]),
3400
3875
  tool(archiveNames[2], "history", "Read the locally stored history of any thread in the configured WeChat account, newest first, without sender filtering or read marking. Use wechat_archive_threads to find thread IDs.", { threadId: messageIdSchema, ...pageSchema }, ["threadId"]),
3401
3876
  tool(archiveNames[3], "message", "Look up a stored message in any thread of the configured WeChat account by its exact opaque message ID. Use wechat_archive_media to retrieve its attachment.", { messageId: messageIdSchema }, ["messageId"]),
3402
- tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
3877
+ tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image/link from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
3403
3878
  ];
3404
3879
  return [
3405
3880
  tool(names[0], "history", "Read the current WeChat conversation's locally stored messages, newest first, beyond the recent inbound buffer. Follow nextCursor for older history; no remote history backfill or read marking.", { ...pageSchema }),
3406
3881
  tool(names[1], "search", "Search the current WeChat conversation's locally stored message text by case-insensitive substring, beyond buffered messages. Follow nextCursor with the same query in this conversation.", { query: querySchema, ...pageSchema }, ["query"]),
3407
3882
  tool(names[2], "message", "Look up a stored message by its exact opaque message ID in the current WeChat conversation. Media is described without downloading it; use wechat_media to retrieve its attachment. Long text is bounded.", { messageId: messageIdSchema }, ["messageId"]),
3408
- tool(names[3], "media", "Retrieve a message attachment or quoted text/image from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
3883
+ tool(names[3], "media", "Retrieve a message attachment or quoted text/image/link from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
3409
3884
  ];
3410
3885
  }
3411
3886
  function registerNativeHistoryTools(api) {
@@ -3471,7 +3946,7 @@ function createNativeShareTools(context, dependencies = {}) {
3471
3946
  ...args.description !== void 0 ? { description: args.description === "" ? "" : text(args.description, "description", 16384, true) } : {},
3472
3947
  ...args.thumbnailUrl !== void 0 ? { thumbnailUrl: url(args.thumbnailUrl, "thumbnail URL") } : {}
3473
3948
  };
3474
- const guard = () => {
3949
+ const guard2 = () => {
3475
3950
  signal?.throwIfAborted();
3476
3951
  context.assertInvocationCurrent?.();
3477
3952
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -3480,7 +3955,7 @@ function createNativeShareTools(context, dependencies = {}) {
3480
3955
  };
3481
3956
  const core = dependencies.core ?? getWeChatRuntime();
3482
3957
  const authorize = async () => {
3483
- const { cfg, account } = guard();
3958
+ const { cfg, account } = guard2();
3484
3959
  const result = await resolveWeChatIngress({
3485
3960
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress3,
3486
3961
  cfg,
@@ -3491,11 +3966,11 @@ function createNativeShareTools(context, dependencies = {}) {
3491
3966
  senderId: requester,
3492
3967
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3493
3968
  });
3494
- guard();
3969
+ guard2();
3495
3970
  if (result.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3496
3971
  };
3497
3972
  await authorize();
3498
- const { native } = guard();
3973
+ const { native } = guard2();
3499
3974
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3500
3975
  await authorize();
3501
3976
  let intent;
@@ -3530,7 +4005,7 @@ function registerNativeShareTools(api) {
3530
4005
  }
3531
4006
 
3532
4007
  // src/native-recall-tools.ts
3533
- import { createHash as createHash9 } from "node:crypto";
4008
+ import { createHash as createHash10 } from "node:crypto";
3534
4009
  import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress4 } from "openclaw/plugin-sdk/channel-ingress-runtime";
3535
4010
  var names2 = ["wechat_recall"];
3536
4011
  function text2(value, label) {
@@ -3541,7 +4016,7 @@ function thread2(value) {
3541
4016
  return text2(text2(value, "conversation").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "conversation");
3542
4017
  }
3543
4018
  function nativeRecallKey(accountId, threadId2, messageId, serverId) {
3544
- return `openclaw-recall-${createHash9("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
4019
+ return `openclaw-recall-${createHash10("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
3545
4020
  }
3546
4021
  function createNativeRecallTools(context, dependencies = {}) {
3547
4022
  const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
@@ -3573,7 +4048,7 @@ function createNativeRecallTools(context, dependencies = {}) {
3573
4048
  const messageId = text2(input.messageId, "message ID");
3574
4049
  const serverId = text2(input.serverId, "server ID");
3575
4050
  if (!/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n) throw Error("Invalid exact server message ID");
3576
- const guard = () => {
4051
+ const guard2 = () => {
3577
4052
  signal?.throwIfAborted();
3578
4053
  context.assertInvocationCurrent?.();
3579
4054
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -3581,7 +4056,7 @@ function createNativeRecallTools(context, dependencies = {}) {
3581
4056
  return { cfg, account, native: account.native };
3582
4057
  };
3583
4058
  const allowed = async () => {
3584
- const { cfg, account } = guard();
4059
+ const { cfg, account } = guard2();
3585
4060
  const core = dependencies.core ?? getWeChatRuntime();
3586
4061
  const ingress = await resolveWeChatIngress({
3587
4062
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress4,
@@ -3593,20 +4068,20 @@ function createNativeRecallTools(context, dependencies = {}) {
3593
4068
  senderId: requester,
3594
4069
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3595
4070
  });
3596
- guard();
4071
+ guard2();
3597
4072
  if (ingress.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3598
4073
  };
3599
4074
  await allowed();
3600
- const { native } = guard();
4075
+ const { native } = guard2();
3601
4076
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3602
- guard();
4077
+ guard2();
3603
4078
  const message = await client.message(accountId, messageId);
3604
- guard();
4079
+ guard2();
3605
4080
  const retainedRecall = !!nativeRecallInfo(message);
3606
4081
  if (message.id !== messageId || message.accountId !== accountId || message.threadId !== conversation || message.direction !== "outgoing" && !retainedRecall) throw Error("Recall target is outside this conversation or was not sent by this account");
3607
4082
  if (message.serverId !== serverId) throw Error("The exact server identity does not match this receipt; the local message identity may have been reused");
3608
4083
  await allowed();
3609
- guard();
4084
+ guard2();
3610
4085
  const key = nativeRecallKey(accountId, conversation, messageId, serverId);
3611
4086
  let operation;
3612
4087
  let operationId;
@@ -3636,13 +4111,13 @@ function createNativeRecallTools(context, dependencies = {}) {
3636
4111
  const deadline = Date.now() + Math.max(0, Math.min(dependencies.waitMs ?? 2e3, 1e4));
3637
4112
  while (["queued", "running", "submitting"].includes(operation.state) && Date.now() < deadline) {
3638
4113
  await new Promise((resolve) => setTimeout(resolve, Math.min(250, deadline - Date.now())));
3639
- guard();
4114
+ guard2();
3640
4115
  const remaining = Math.max(1, deadline - Date.now());
3641
4116
  const next = await client.operation(accountId, operation.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
3642
4117
  checkOperation(next);
3643
4118
  operation = next;
3644
4119
  }
3645
- guard();
4120
+ guard2();
3646
4121
  } catch (error) {
3647
4122
  const reason = error instanceof NativeApiError ? error.code : "recall_result_unavailable";
3648
4123
  return result("needs_review", { reason, note: "Inspect the existing operation or repeat only this same message with the same idempotency key. No automatic second submission was made." });
@@ -3664,6 +4139,55 @@ function registerNativeRecallTools(api) {
3664
4139
  api.registerTool({ contextVersion: 2, create: (context) => createNativeRecallTools(context) }, { names: names2 });
3665
4140
  }
3666
4141
 
4142
+ // src/native-group-tool.ts
4143
+ var name2 = "wechat_group_info";
4144
+ function createNativeGroupTools(context, dependencies = {}) {
4145
+ if (context.senderIsOwner !== true || typeof context.assertInvocationCurrent !== "function") return [];
4146
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
4147
+ const selected = resolveWeChatAccount(currentConfig());
4148
+ 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 [];
4149
+ const accountId = selected.native.accountId;
4150
+ const configurationKey = nativeConfigurationKey(selected.native);
4151
+ return [{
4152
+ name: name2,
4153
+ label: "WeChat Group Info",
4154
+ 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.",
4155
+ parameters: { type: "object", additionalProperties: false, properties: {
4156
+ groupId: { type: "string", minLength: 10, maxLength: 256, description: "Exact ordinary group username ending in @chatroom." },
4157
+ limit: { type: "integer", minimum: 1, maximum: 100 },
4158
+ cursor: { type: "string", maxLength: 8192 },
4159
+ username: { type: "string", minLength: 1, maxLength: 256, description: "Optionally inspect one exact member instead of listing a page." }
4160
+ }, required: ["groupId"] },
4161
+ execute: async (_callId, input, signal) => {
4162
+ 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");
4163
+ const args = input;
4164
+ const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/u.test(value);
4165
+ 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");
4166
+ const guard2 = () => {
4167
+ signal?.throwIfAborted();
4168
+ if (context.senderIsOwner !== true) throw Error("OpenClaw owner authorization is required");
4169
+ context.assertInvocationCurrent();
4170
+ const account = resolveWeChatAccount(currentConfig());
4171
+ 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");
4172
+ return account.native;
4173
+ };
4174
+ const native = guard2();
4175
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4176
+ guard2();
4177
+ const group = await client.group(accountId, args.groupId, signal);
4178
+ guard2();
4179
+ if (group.id !== args.groupId || group.roomId !== args.groupId) throw Error("Group identity is invalid");
4180
+ 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);
4181
+ guard2();
4182
+ const details = { accountId, group, members, source: "cached", membershipAbsence: "unconfirmed" };
4183
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
4184
+ }
4185
+ }];
4186
+ }
4187
+ function registerNativeGroupTools(api) {
4188
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeGroupTools(context) }, { names: [name2] });
4189
+ }
4190
+
3667
4191
  // index.ts
3668
4192
  var plugin = {
3669
4193
  id: "agent-wechat",
@@ -3677,6 +4201,8 @@ var plugin = {
3677
4201
  registerNativeHistoryTools(api);
3678
4202
  registerNativeShareTools(api);
3679
4203
  registerNativeRecallTools(api);
4204
+ registerNativeGroupTools(api);
4205
+ registerNativeLoginTool(api);
3680
4206
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
3681
4207
  };
3682
4208
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {