@agent-wechat/agent-wechat 1.0.0-beta.7 → 1.0.0-beta.9

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
@@ -110,7 +110,7 @@ import { constants } from "node:fs";
110
110
  import { access, lstat, readFile, realpath } from "node:fs/promises";
111
111
  import { spawn, execFile } from "node:child_process";
112
112
  import { promisify } from "node:util";
113
- import { delimiter, dirname, isAbsolute, join } from "node:path";
113
+ import { delimiter, dirname, isAbsolute, join, toNamespacedPath } from "node:path";
114
114
  import { createConnection } from "node:net";
115
115
 
116
116
  // ../shared/dist/native.generated.js
@@ -146,13 +146,13 @@ var NativeApiError = class extends Error {
146
146
  }
147
147
  };
148
148
  function validateNativeBaseUrl(input) {
149
- const url = new URL(input);
150
- const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname.toLowerCase());
151
- if (url.username || url.password || url.search || url.hash || !["http:", "https:"].includes(url.protocol) || url.protocol === "http:" && !loopback)
149
+ const url2 = new URL(input);
150
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url2.hostname.toLowerCase());
151
+ if (url2.username || url2.password || url2.search || url2.hash || !["http:", "https:"].includes(url2.protocol) || url2.protocol === "http:" && !loopback)
152
152
  throw new Error("Native API requires verified HTTPS or a loopback HTTP endpoint, without embedded credentials");
153
- if (!url.pathname.endsWith("/"))
154
- url.pathname += "/";
155
- return url;
153
+ if (!url2.pathname.endsWith("/"))
154
+ url2.pathname += "/";
155
+ return url2;
156
156
  }
157
157
  var id = (value) => {
158
158
  if (typeof value !== "string" || !value || value === "." || value === ".." || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value))
@@ -283,8 +283,24 @@ var NativeWeChatClient = class {
283
283
  throw new Error("Invalid login idempotency key");
284
284
  return this.request(`v1/accounts/${id(accountId)}/auth/login`, { method: "POST", headers: key ? { "Idempotency-Key": key } : void 0, body: JSON.stringify(options) });
285
285
  }
286
- operation(accountId, operationId) {
287
- return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`);
286
+ operation(accountId, operationId, signal) {
287
+ return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { signal });
288
+ }
289
+ /** Submit once with a stable key; inspect the returned operation after uncertainty. */
290
+ async recallMessage(accountId, messageId, serverId, idempotencyKey, signal) {
291
+ if (typeof serverId !== "string" || !/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n)
292
+ throw new Error("Invalid exact recall server ID");
293
+ if (typeof idempotencyKey !== "string" || !idempotencyKey || idempotencyKey.length > 200 || /[\r\n]/.test(idempotencyKey))
294
+ throw new Error("Invalid recall idempotency key");
295
+ const operation = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/recall`, {
296
+ method: "POST",
297
+ headers: { "Idempotency-Key": idempotencyKey },
298
+ body: JSON.stringify({ serverId }),
299
+ signal
300
+ });
301
+ if (!operation || operation.accountId !== accountId || operation.kind !== "message_recall" || typeof operation.id !== "string" || !operation.id || typeof operation.state !== "string")
302
+ throw new Error("Native recall operation identity is invalid");
303
+ return operation;
288
304
  }
289
305
  cancelOperation(accountId, operationId) {
290
306
  return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { method: "DELETE" });
@@ -395,14 +411,19 @@ var NativeWeChatClient = class {
395
411
  }
396
412
  return result;
397
413
  }
398
- sendText(accountId, threadId2, text, idempotencyKey) {
414
+ sendText(accountId, threadId2, text3, idempotencyKey) {
399
415
  if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
400
416
  throw new Error("An idempotency key is required");
401
- return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text }) });
417
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text: text3 }) });
402
418
  }
403
419
  send(accountId, sendId, signal) {
404
420
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
405
421
  }
422
+ sendShare(accountId, input, key, signal) {
423
+ if (!key || key.length > 200 || /[\u0000-\u001f\u007f]/.test(key))
424
+ throw new Error("An idempotency key is required");
425
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(input), signal });
426
+ }
406
427
  sendByKey(accountId, key) {
407
428
  if (!key || key.length > 200)
408
429
  throw new Error("Invalid idempotency key");
@@ -411,11 +432,11 @@ var NativeWeChatClient = class {
411
432
  cancelSend(accountId, sendId) {
412
433
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
413
434
  }
414
- async upload(accountId, bytes, name, contentType = "application/octet-stream", signal) {
415
- if (!bytes.length || bytes.length > NATIVE_MAX_SOURCE_BYTES || !name || name.length > 255 || /[\r\n/\\]/.test(name))
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))
416
437
  throw new Error("Invalid bounded media upload");
417
438
  const form = new FormData();
418
- form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }), name);
439
+ form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }), name2);
419
440
  return this.request(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal });
420
441
  }
421
442
  sendMedia(accountId, input, key) {
@@ -448,15 +469,16 @@ var NativeWeChatClient = class {
448
469
  }
449
470
  async mediaContent(accountId, assetId, options = {}) {
450
471
  const max = options.maxBytes ?? 128 * 1024 * 1024;
451
- if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range))
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))
452
473
  throw new Error("Invalid bounded content request");
453
- const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content`, { signal: options.signal, headers: options.range ? { Range: options.range } : {} });
474
+ 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 } : {} });
454
476
  const length = response.headers.get("content-length");
455
477
  if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
456
478
  await response.body?.cancel();
457
479
  throw new Error("Native media response exceeds its bound");
458
480
  }
459
- 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"), status: response.status };
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 };
460
482
  }
461
483
  markRead(accountId, threadId2, expectedVersion) {
462
484
  if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
@@ -576,11 +598,22 @@ function requireNativeIdentity(value) {
576
598
  }
577
599
  }
578
600
  function nativeChildEnvironment(token, environment = process.env) {
579
- return Object.fromEntries(["PATH", "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR"].filter((name) => environment[name] !== void 0 && environment[name] !== token).map((name) => [name, environment[name]]));
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]]));
580
602
  }
581
603
  function nativeHostTarget(platform = process.platform, arch = process.arch) {
582
- if (platform !== "linux" || !["x64", "arm64"].includes(arch)) throw new Error("native_binary_incompatible: managed hosting requires Linux x64 or ARM64; use external mode for remote daemons");
583
- return { arch, rustTarget: arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" };
604
+ const cpu = arch === "arm64" ? "aarch64" : "x86_64";
605
+ const suffix = { linux: "unknown-linux-gnu", darwin: "apple-darwin", win32: "pc-windows-msvc" }[platform];
606
+ if (!suffix || !["x64", "arm64"].includes(arch) || platform === "darwin" && arch !== "arm64") throw new Error("native_binary_incompatible: unsupported host platform; use external mode for remote daemons");
607
+ return { platform, arch, rustTarget: `${cpu}-${suffix}`, key: `${platform}-${arch}${platform === "linux" ? "-gnu" : ""}`, executable: platform === "win32" ? "wx.exe" : "wx" };
608
+ }
609
+ function nativeExecutableFormat(bytes, platform = process.platform) {
610
+ if (platform === "linux") return bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70]));
611
+ if (platform === "darwin") return bytes.subarray(0, 4).equals(Buffer.from([207, 250, 237, 254]));
612
+ if (platform === "win32" && bytes.length >= 64 && bytes[0] === 77 && bytes[1] === 90) {
613
+ const offset = bytes.readUInt32LE(60);
614
+ return offset >= 64 && offset <= bytes.length - 4 && bytes.subarray(offset, offset + 4).equals(Buffer.from([80, 69, 0, 0]));
615
+ }
616
+ return false;
584
617
  }
585
618
  async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "", environment = nativeChildEnvironment("")) {
586
619
  const target = nativeHostTarget();
@@ -589,40 +622,48 @@ async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "",
589
622
  if (!candidate) {
590
623
  for (const directory of path.split(delimiter).filter(isAbsolute)) {
591
624
  try {
592
- await access(join(directory, "wx"), constants.X_OK);
593
- candidate = join(directory, "wx");
625
+ await access(join(directory, target.executable), constants.X_OK);
626
+ candidate = join(directory, target.executable);
594
627
  break;
595
628
  } catch {
596
629
  }
630
+ if (process.platform === "win32") {
631
+ const payload = join(directory, "node_modules", "@agent-wechat", "cli", "scripts", "native-launcher.mjs");
632
+ try {
633
+ await access(payload);
634
+ candidate = payload;
635
+ break;
636
+ } catch {
637
+ }
638
+ }
597
639
  }
598
640
  }
599
641
  if (!candidate) throw new Error("native_binary_missing: install native wx separately or configure its absolute path");
600
642
  candidate = await realpath(candidate);
601
- if (candidate.endsWith("/scripts/native-launcher.mjs")) {
643
+ if (candidate.replaceAll("\\", "/").endsWith("/scripts/native-launcher.mjs")) {
602
644
  const root = dirname(dirname(candidate));
603
645
  const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
604
646
  if (pkg.name !== "@agent-wechat/cli" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)) throw new Error("Unsupported native CLI launcher");
605
647
  const manifest = JSON.parse(await readFile(join(root, "native-release.json"), "utf8"));
606
- const asset = manifest.assets?.find((value) => value.rustTarget === target.rustTarget && value.platform === "linux" && value.arch === target.arch && value.libc === "gnu");
648
+ const asset = manifest.assets?.find((value) => value.rustTarget === target.rustTarget && value.platform === target.platform && value.arch === target.arch && (target.platform !== "linux" || value.libc === "gnu"));
607
649
  if (manifest.version !== pkg.version || !asset || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("Native CLI manifest is incompatible");
608
- candidate = await realpath(join(root, "native", pkg.version, `linux-${target.arch}-gnu`, "wx"));
650
+ candidate = await realpath(join(root, "native", pkg.version, target.key, target.executable));
609
651
  const bytes = await readFile(candidate);
610
652
  if (bytes.length !== asset.bytes || createHash2("sha256").update(bytes).digest("hex") !== asset.sha256) throw new Error("Installed native executable checksum mismatch");
611
653
  }
612
654
  const stat = await lstat(candidate);
613
655
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Native executable must be a regular file");
614
656
  await access(candidate, constants.X_OK);
615
- const magic = (await readFile(candidate)).subarray(0, 4);
616
- if (!magic.equals(Buffer.from([127, 69, 76, 70]))) throw new Error("native_binary_required: configure the actual native ELF executable, not an unverified process wrapper");
657
+ if (!nativeExecutableFormat(await readFile(candidate))) throw new Error("native_binary_required: configure the actual native executable for this OS");
617
658
  const { stdout } = await exec(candidate, ["--json", "version"], { env: environment, timeout: 5e3, maxBuffer: 65536 });
618
659
  const version = JSON.parse(stdout);
619
660
  requireNativeIdentity(version);
620
661
  if (version.target !== target.rustTarget) throw new Error("native_binary_incompatible: executable architecture differs from this host");
621
662
  return candidate;
622
663
  }
623
- async function portOccupied(url) {
664
+ async function portOccupied(url2) {
624
665
  return new Promise((resolve, reject) => {
625
- const socket = createConnection({ host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port || (url.protocol === "https:" ? 443 : 80)) });
666
+ const socket = createConnection({ host: url2.hostname.replace(/^\[|\]$/g, ""), port: Number(url2.port || (url2.protocol === "https:" ? 443 : 80)) });
626
667
  socket.setTimeout(1e3);
627
668
  socket.once("connect", () => {
628
669
  socket.destroy();
@@ -639,7 +680,7 @@ async function portOccupied(url) {
639
680
  });
640
681
  });
641
682
  }
642
- async function stopOwnedChild(child) {
683
+ async function stopOwnedChild(child, graceful) {
643
684
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
644
685
  await new Promise((resolve) => {
645
686
  const kill = setTimeout(() => {
@@ -649,7 +690,10 @@ async function stopOwnedChild(child) {
649
690
  clearTimeout(kill);
650
691
  resolve();
651
692
  });
652
- child.kill("SIGTERM");
693
+ if (graceful) void graceful().catch(() => {
694
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
695
+ });
696
+ else child.kill("SIGTERM");
653
697
  });
654
698
  }
655
699
  async function startNativeHost(config) {
@@ -664,11 +708,11 @@ async function startNativeHost(config) {
664
708
  });
665
709
  }
666
710
  if (config.mode !== "managed" || config.endpoint || !config.dataDir || !isAbsolute(config.dataDir) || config.serverConfig && !isAbsolute(config.serverConfig)) throw new Error("Managed native mode requires absolute dataDir/config paths and no remote endpoint");
667
- const url = validateNativeBaseUrl(`http://${config.listen ?? "127.0.0.1:6174"}`);
668
- if (await portOccupied(url)) throw new Error("native_owner_conflict: configured port is already owned; use external mode for an independently operated daemon");
711
+ const url2 = validateNativeBaseUrl(`http://${config.listen ?? "127.0.0.1:6174"}`);
712
+ if (await portOccupied(url2)) throw new Error("native_owner_conflict: configured port is already owned; use external mode for an independently operated daemon");
669
713
  const executable = await resolveNativeExecutable(config.executable, void 0, nativeChildEnvironment(token));
670
- const client = new NativeWeChatClient({ baseUrl: url.href, token, timeoutMs: 1e3 });
671
- const args = ["serve", "--data-dir", config.dataDir, "--token-stdin", "--listen", url.host];
714
+ const client = new NativeWeChatClient({ baseUrl: url2.href, token, timeoutMs: 1e3 });
715
+ const args = ["serve", "--data-dir", config.dataDir, "--token-stdin", "--listen", url2.host];
672
716
  if (config.serverConfig) args.push("--server-config", config.serverConfig);
673
717
  const child = spawn(executable, args, { env: nativeChildEnvironment(token), stdio: ["pipe", "ignore", "pipe"], shell: false });
674
718
  child.stderr?.on("data", () => {
@@ -691,10 +735,21 @@ async function startNativeHost(config) {
691
735
  if (exited) throw new Error("Native owned child exited before readiness");
692
736
  try {
693
737
  const status = await client.status();
694
- const dataDir = await realpath(config.dataDir);
738
+ const canonical = await realpath(config.dataDir);
739
+ const dataDir = process.platform === "win32" ? toNamespacedPath(canonical) : canonical;
695
740
  if (exited || status.version.backend !== "native" || status.version.apiVersion !== 1 || status.pid !== child.pid || status.dataDir !== dataDir || typeof status.serverId !== "string" || typeof status.ownershipGeneration !== "string") throw new Error("native_owner_conflict: readiness identity does not match the owned process/store");
696
741
  requireNativeIdentity(status.version);
697
- return host(new NativeWeChatClient({ baseUrl: url.href, token }), token, () => stopOwnedChild(child));
742
+ return host(new NativeWeChatClient({ baseUrl: url2.href, token }), token, () => stopOwnedChild(child, async () => {
743
+ const response = await fetch(new URL("v1/daemon/shutdown", url2), {
744
+ method: "POST",
745
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
746
+ body: JSON.stringify({ pid: status.pid, serverId: status.serverId, ownershipGeneration: status.ownershipGeneration }),
747
+ redirect: "error",
748
+ signal: AbortSignal.timeout(3e3)
749
+ });
750
+ if (!response.ok) throw new Error("Owned daemon refused graceful shutdown");
751
+ await response.body?.cancel();
752
+ }));
698
753
  } catch (error) {
699
754
  if (String(error).includes("native_owner_conflict")) throw error;
700
755
  await new Promise((resolve) => setTimeout(resolve, 100));
@@ -1023,25 +1078,25 @@ function appendReference(body, reference) {
1023
1078
  return body ? `${body}
1024
1079
  ${reference}` : reference;
1025
1080
  }
1026
- function safeDisplayName(name) {
1027
- return name.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1081
+ function safeDisplayName(name2) {
1082
+ return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1028
1083
  }
1029
1084
  function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
1030
1085
  if (!attachment) return originalBody;
1031
- const name = safeDisplayName(attachment.filename);
1086
+ const name2 = safeDisplayName(attachment.filename);
1032
1087
  if (attachment.status !== "ready" || !attachment.path) {
1033
1088
  const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
1034
1089
  if (attachment.status === "pending") {
1035
- return appendReference(originalBody, `[${label} pending: ${name}]`);
1090
+ return appendReference(originalBody, `[${label} pending: ${name2}]`);
1036
1091
  }
1037
1092
  if (attachment.status === "expired") {
1038
- return appendReference(originalBody, `[${label} expired: ${name}; no cached copy available. Ask the sender to resend.]`);
1093
+ return appendReference(originalBody, `[${label} expired: ${name2}; no cached copy available. Ask the sender to resend.]`);
1039
1094
  }
1040
- return appendReference(originalBody, `[${label} unavailable: ${name} (${attachment.status})]`);
1095
+ return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
1041
1096
  }
1042
1097
  if (attachment.kind === "file") {
1043
- const prefix = originalBody.trim() === name ? "" : originalBody;
1044
- return appendReference(prefix, `[File: ${name}]
1098
+ const prefix = originalBody.trim() === name2 ? "" : originalBody;
1099
+ return appendReference(prefix, `[File: ${name2}]
1045
1100
  [Local file: <${attachment.path}>]`);
1046
1101
  }
1047
1102
  if (exposeNonVisualPath) {
@@ -1060,11 +1115,117 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
1060
1115
  return body;
1061
1116
  }
1062
1117
 
1118
+ // src/native-send.ts
1119
+ async function nativeOutboundDelivery(client, accountId, threadId2, messageIds, source) {
1120
+ const receipts = [];
1121
+ try {
1122
+ if (!Array.isArray(messageIds) || !messageIds.length || messageIds.length > 256 || messageIds.some((id2) => typeof id2 !== "string" || !id2 || id2.length > 4096) || new Set(messageIds).size !== messageIds.length) throw Error("Message receipts unavailable");
1123
+ for (const [index, messageId] of messageIds.entries()) {
1124
+ const message = await client.message(accountId, messageId);
1125
+ if (message.id !== messageId || message.accountId !== accountId || message.threadId !== threadId2 || message.direction !== "outgoing" || typeof message.serverId !== "string" || !/^[1-9]\d{0,19}$/.test(message.serverId) || BigInt(message.serverId) > 18446744073709551615n || source.serverIds?.[index] != null && source.serverIds[index] !== message.serverId) throw Error("Message receipt identity mismatch");
1126
+ receipts.push({ messageId, serverId: message.serverId, accountId, threadId: threadId2 });
1127
+ }
1128
+ } catch {
1129
+ return { messageId: "", meta: {
1130
+ accountId,
1131
+ threadId: threadId2,
1132
+ ...source,
1133
+ messageIds: receipts,
1134
+ outcome: "unknown",
1135
+ receiptState: "needs_review",
1136
+ retryable: false,
1137
+ note: "Send submitted. Inspect the durable send/job and retained receipts; do not resend automatically."
1138
+ } };
1139
+ }
1140
+ return { messageId: receipts[0].messageId, meta: {
1141
+ accountId,
1142
+ threadId: threadId2,
1143
+ serverId: receipts[0].serverId,
1144
+ outcome: "sent",
1145
+ ...source,
1146
+ messageIds: receipts,
1147
+ receiptState: "verified"
1148
+ } };
1149
+ }
1150
+ async function submitNativeText(client, accountId, threadId2, text3, key, options = {}) {
1151
+ let intent = await client.sendText(accountId, threadId2, text3, key);
1152
+ 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));
1154
+ while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1155
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1156
+ const remaining = deadline - Date.now();
1157
+ if (remaining <= 0) break;
1158
+ intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1159
+ delay = Math.min(delay * 2, 1e3);
1160
+ }
1161
+ if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1162
+ return intent;
1163
+ }
1164
+
1165
+ // src/native-recall-state.ts
1166
+ function nativeRecallInfo(message) {
1167
+ return message.recall;
1168
+ }
1169
+ function nativeRecallIdentity(threadId2, serverId) {
1170
+ return JSON.stringify([threadId2, serverId]);
1171
+ }
1172
+ function validateNativeRecall(value) {
1173
+ 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
+ }
1175
+ function recalledNativeMessage(message, recall) {
1176
+ return {
1177
+ ...message,
1178
+ type: 1e4,
1179
+ body: "[Message recalled]",
1180
+ quote: void 0,
1181
+ link: void 0,
1182
+ media: [],
1183
+ mentions: { state: "unknown", userIds: [], self: null, all: null, source: null },
1184
+ recall: { observedAt: recall.observedAt, ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {} }
1185
+ };
1186
+ }
1187
+
1063
1188
  // src/native-media.ts
1064
1189
  function kind(type) {
1065
1190
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
1066
1191
  }
1067
- var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
1192
+ var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "partial", "cancelled", "expired", "needs_review"]);
1193
+ var successfulSendJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded"]);
1194
+ var terminalIdentityErrors = /* @__PURE__ */ new Set(["media_identity_unavailable", "media_source_changed"]);
1195
+ function mediaSendResult(job) {
1196
+ return job.result && typeof job.result === "object" && !Array.isArray(job.result) ? job.result : {};
1197
+ }
1198
+ function mediaSendReceiptIds(job, audioAsVoice) {
1199
+ const result = mediaSendResult(job), ids = result.messageIds;
1200
+ if (!successfulSendJobs.has(job.state) || result.profileProjection === "unknown" || !Array.isArray(ids) || !ids.length || ids.length > 256 || ids.some((id2) => typeof id2 !== "string" || !id2 || id2.length > 4096) || new Set(ids).size !== ids.length) return void 0;
1201
+ if (audioAsVoice) {
1202
+ const chunks = result.chunks;
1203
+ if (!Array.isArray(chunks) || !chunks.length || chunks.length !== ids.length || chunks.some((chunk) => !chunk || chunk.state !== "accepted" || typeof chunk.serverMessageId !== "string")) return void 0;
1204
+ } else if (ids.length !== 1) return void 0;
1205
+ return ids;
1206
+ }
1207
+ async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial, audioAsVoice, timeoutMs = 1e4) {
1208
+ 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
+ const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 1e4));
1210
+ let job = initial, delay = 100;
1211
+ while (successfulSendJobs.has(job.state) && !mediaSendReceiptIds(job, audioAsVoice) && Date.now() < deadline) {
1212
+ const result = mediaSendResult(job);
1213
+ 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()))));
1215
+ const remaining = deadline - Date.now();
1216
+ if (remaining <= 0) break;
1217
+ let next;
1218
+ try {
1219
+ next = await client.job(accountId, initial.id, AbortSignal.timeout(remaining));
1220
+ } catch {
1221
+ break;
1222
+ }
1223
+ 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
+ job = next;
1225
+ delay = Math.min(delay * 2, 1e3);
1226
+ }
1227
+ return job;
1228
+ }
1068
1229
  async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1069
1230
  const deadline = Date.now() + timeoutMs;
1070
1231
  let job = initial;
@@ -1102,6 +1263,43 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
1102
1263
  }
1103
1264
  throw Error("Native model image representation exceeds its bound");
1104
1265
  }
1266
+ async function prepareNativeLink(params) {
1267
+ const { message } = params;
1268
+ const link = message.link;
1269
+ const body = [`[Link]${link.title ? ` ${link.title}` : ""}`, link.description, link.url].filter(Boolean).join("\n").slice(0, 65536);
1270
+ const unavailable = (status, reason) => ({
1271
+ body: `${body}
1272
+ [Link preview ${status}]`,
1273
+ pending: false,
1274
+ retryable: false,
1275
+ reason,
1276
+ attachment: { kind: "image", status, filename: "link-preview" }
1277
+ });
1278
+ if (params.quality && !["best", "thumbnail"].includes(params.quality)) return unavailable("unsupported", "unsupported_media_variant");
1279
+ let prepared;
1280
+ try {
1281
+ prepared = await prepareNativeAttachment({
1282
+ ...params,
1283
+ quality: "thumbnail",
1284
+ message: { ...message, type: 3, appMessageType: void 0, quote: void 0, link: void 0 }
1285
+ });
1286
+ } catch (error) {
1287
+ params.signal?.throwIfAborted();
1288
+ params.assertCurrent?.();
1289
+ if (!(error instanceof NativeApiError)) throw error;
1290
+ if (terminalIdentityErrors.has(error.code) || error.code === "message_recalled") throw error;
1291
+ return { ...unavailable("unavailable", error.code), retryable: error.retryable };
1292
+ }
1293
+ if (prepared.reason === "message_recalled" || terminalIdentityErrors.has(prepared.reason ?? "")) return prepared;
1294
+ return {
1295
+ ...prepared,
1296
+ body: `${body}
1297
+ [Link preview]
1298
+ ${prepared.body}`,
1299
+ pending: false,
1300
+ ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "link-preview" } } : {}
1301
+ };
1302
+ }
1105
1303
  async function prepareNativeQuote(params) {
1106
1304
  const { client, account, message } = params;
1107
1305
  const guard = () => {
@@ -1111,7 +1309,6 @@ async function prepareNativeQuote(params) {
1111
1309
  guard();
1112
1310
  const comment = (message.body ?? "").slice(0, 16384);
1113
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 } });
1114
- if (!message.quote) return unavailable("invalid");
1115
1312
  await params.beforeMediaAccess?.();
1116
1313
  guard();
1117
1314
  let resolution;
@@ -1120,10 +1317,13 @@ async function prepareNativeQuote(params) {
1120
1317
  } catch (error) {
1121
1318
  guard();
1122
1319
  if (!(error instanceof NativeApiError)) throw error;
1123
- return unavailable("unavailable");
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");
1124
1322
  }
1125
1323
  guard();
1126
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");
1127
1327
  if (resolution.state === "embedded") {
1128
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");
1129
1329
  const author = { senderId: resolution.senderId ?? null, direction: null };
@@ -1181,6 +1381,25 @@ async function prepareNativeQuote(params) {
1181
1381
  };
1182
1382
  }
1183
1383
  async function prepareNativeAttachment(params) {
1384
+ try {
1385
+ return await prepareNativeAttachmentInput(params);
1386
+ } catch (error) {
1387
+ params.signal?.throwIfAborted();
1388
+ params.assertCurrent?.();
1389
+ if (!(error instanceof NativeApiError) || !terminalIdentityErrors.has(error.code)) throw error;
1390
+ if (params.message.type === 49 && params.message.appMessageType === 57) return {
1391
+ body: `${(params.message.body ?? "").slice(0, 16384)}
1392
+ [Quoted message unavailable: stored source identity could not be verified]`,
1393
+ quote: { state: "unavailable" },
1394
+ pending: false,
1395
+ retryable: false,
1396
+ reason: error.code
1397
+ };
1398
+ const attachment = { kind: kind(params.message.type) ?? "file", filename: "attachment", status: "unavailable" };
1399
+ return { body: renderAttachmentBody("", attachment), attachment, pending: false, retryable: false, reason: error.code };
1400
+ }
1401
+ }
1402
+ async function prepareNativeAttachmentInput(params) {
1184
1403
  const { client, account, message, core } = params;
1185
1404
  const attachmentKind = kind(message.type);
1186
1405
  const guard = () => {
@@ -1188,6 +1407,7 @@ async function prepareNativeAttachment(params) {
1188
1407
  params.assertCurrent?.();
1189
1408
  };
1190
1409
  guard();
1410
+ if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1191
1411
  const authorize = async () => {
1192
1412
  guard();
1193
1413
  await params.beforeMediaAccess?.();
@@ -1195,6 +1415,7 @@ async function prepareNativeAttachment(params) {
1195
1415
  };
1196
1416
  if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
1197
1417
  if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
1418
+ if (message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && message.link) return prepareNativeLink(params);
1198
1419
  if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
1199
1420
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1200
1421
  const accountId = account.native.accountId;
@@ -1222,34 +1443,38 @@ async function prepareNativeAttachment(params) {
1222
1443
  }
1223
1444
  let job;
1224
1445
  let transferError;
1225
- if (!asset && initialCandidate?.jobId) {
1446
+ if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1226
1447
  await authorize();
1227
1448
  job = await client.job(accountId, initialCandidate.jobId, params.signal);
1228
1449
  guard();
1229
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");
1230
1451
  assets = await scopedAssets();
1231
1452
  asset = select(assets, message.type, quality);
1453
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1232
1454
  if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1233
1455
  }
1234
- if (!asset && !job && params.download !== false && !["expired", "unsupported"].includes(transferCandidate()?.state ?? "")) {
1456
+ if (!asset && !job && params.download !== false && !["expired", "unsupported", "recalled"].includes(transferCandidate()?.state ?? "")) {
1457
+ await authorize();
1235
1458
  try {
1236
- await authorize();
1237
1459
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1238
1460
  guard();
1239
1461
  job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1240
1462
  guard();
1241
1463
  } catch (error) {
1242
1464
  guard();
1243
- if (!(error instanceof NativeApiError) || error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1465
+ if (!(error instanceof NativeApiError) || terminalIdentityErrors.has(error.code)) throw error;
1466
+ if (error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1244
1467
  transferError = error;
1245
1468
  }
1246
1469
  assets = await scopedAssets();
1247
1470
  asset = select(assets, message.type, quality);
1471
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1248
1472
  if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1249
1473
  }
1250
1474
  const candidate = transferCandidate();
1251
1475
  const filename = basename(asset?.filename ?? candidate?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
1252
1476
  if (!asset) {
1477
+ if (candidate?.state === "recalled" || transferError?.code === "message_recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1253
1478
  const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
1254
1479
  const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
1255
1480
  return {
@@ -1285,13 +1510,27 @@ async function prepareNativeAttachment(params) {
1285
1510
  };
1286
1511
  let suppliedPath = saved.path, suppliedMime = mime, suppliedHash = hash, suppliedLength = content.bytes.length;
1287
1512
  let body = renderAttachmentBody("", attachment);
1288
- if (attachmentKind === "image" && params.cfg) {
1289
- const representation = await prepareImageRepresentation(Buffer.from(content.bytes), mime, params.cfg);
1290
- if (representation.preview) {
1513
+ if (attachmentKind === "image" && (params.cfg || mime === "image/x-wxgf")) {
1514
+ let imageBytes = Buffer.from(content.bytes), imageMime = mime;
1515
+ const wxgf = mime === "image/x-wxgf";
1516
+ if (wxgf) {
1517
+ const preview = asset.preview;
1518
+ 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
+ await authorize();
1520
+ const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
1521
+ guard();
1522
+ const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
1523
+ 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
+ imageBytes = Buffer.from(derived.bytes);
1525
+ imageMime = derivedMime;
1526
+ }
1527
+ const representation = await prepareImageRepresentation(imageBytes, imageMime, params.cfg);
1528
+ if (wxgf || representation.preview) {
1529
+ const extension = representation.mime === "image/png" ? "png" : "jpg";
1291
1530
  const normalized = await saveManagedAttachment({
1292
1531
  data: representation.bytes.toString("base64"),
1293
1532
  mime: representation.mime,
1294
- filename: `${filename}.model.jpg`,
1533
+ filename: `${filename}.model.${extension}`,
1295
1534
  maxBytes: 5 * 1024 * 1024,
1296
1535
  saveMediaBuffer: core.channel.media.saveMediaBuffer
1297
1536
  });
@@ -1299,7 +1538,8 @@ async function prepareNativeAttachment(params) {
1299
1538
  suppliedMime = representation.mime;
1300
1539
  suppliedHash = normalized.sha256;
1301
1540
  suppliedLength = normalized.size;
1302
- body += `
1541
+ body += wxgf ? `
1542
+ [Model image: WXGF still preview; original retained separately; animation and transparency are not represented]` : `
1303
1543
  [Model image: prepared still preview; original retained separately]`;
1304
1544
  }
1305
1545
  }
@@ -1330,7 +1570,7 @@ async function prepareNativeAttachment(params) {
1330
1570
  pending: false
1331
1571
  };
1332
1572
  }
1333
- async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
1573
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
1334
1574
  const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
1335
1575
  let bytes;
1336
1576
  let filename;
@@ -1345,7 +1585,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1345
1585
  }
1346
1586
  const reader = response.body?.getReader();
1347
1587
  if (!reader) throw new Error("Native outbound source has no bytes");
1348
- const chunks = [];
1588
+ const chunks2 = [];
1349
1589
  let size = 0;
1350
1590
  try {
1351
1591
  while (true) {
@@ -1353,7 +1593,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1353
1593
  if (done) break;
1354
1594
  size += value.length;
1355
1595
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1356
- chunks.push(value);
1596
+ chunks2.push(value);
1357
1597
  }
1358
1598
  } catch (error) {
1359
1599
  await reader.cancel();
@@ -1363,7 +1603,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1363
1603
  }
1364
1604
  bytes = new Uint8Array(size);
1365
1605
  let offset = 0;
1366
- for (const chunk of chunks) {
1606
+ for (const chunk of chunks2) {
1367
1607
  bytes.set(chunk, offset);
1368
1608
  offset += chunk.length;
1369
1609
  }
@@ -1375,7 +1615,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1375
1615
  try {
1376
1616
  const stat = await file.stat();
1377
1617
  if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1378
- const chunks = [];
1618
+ const chunks2 = [];
1379
1619
  let size = 0;
1380
1620
  while (true) {
1381
1621
  const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
@@ -1383,9 +1623,9 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1383
1623
  if (!bytesRead) break;
1384
1624
  size += bytesRead;
1385
1625
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1386
- chunks.push(chunk.subarray(0, bytesRead));
1626
+ chunks2.push(chunk.subarray(0, bytesRead));
1387
1627
  }
1388
- bytes = Buffer.concat(chunks, size);
1628
+ bytes = Buffer.concat(chunks2, size);
1389
1629
  } finally {
1390
1630
  await file.close();
1391
1631
  }
@@ -1394,43 +1634,39 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1394
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";
1395
1635
  }
1396
1636
  if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1637
+ await beforeSend?.();
1397
1638
  const accountId = account.native.accountId;
1398
1639
  const source = await client.upload(accountId, bytes, filename, mime);
1399
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 };
1400
- const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1401
- if (!["accepted", "completed", "succeeded"].includes(job.state)) throw new Error(`Native media job ${job.id} requires inspection (${job.state}); retain completed items and do not automatically resend`);
1402
- return `agent-wechat-native:${accountId}:${job.id}`;
1403
- }
1404
-
1405
- // src/native-send.ts
1406
- async function submitNativeText(client, accountId, threadId2, text, key, options = {}) {
1407
- let intent = await client.sendText(accountId, threadId2, text, key);
1408
- const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1409
- let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1410
- while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1411
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1412
- const remaining = deadline - Date.now();
1413
- if (remaining <= 0) break;
1414
- intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1415
- delay = Math.min(delay * 2, 1e3);
1416
- }
1417
- if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1418
- return intent;
1641
+ await beforeSend?.();
1642
+ let job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1643
+ 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`);
1645
+ const result = mediaSendResult(job);
1646
+ const chunks = Array.isArray(result.chunks) ? result.chunks.filter((chunk) => chunk && typeof chunk === "object") : [];
1647
+ const serverIds = audioAsVoice ? chunks.filter((chunk) => chunk.state === "accepted" && typeof chunk.serverMessageId === "string").map((chunk) => chunk.serverMessageId) : job.serverMessageId ? [job.serverMessageId] : void 0;
1648
+ return nativeOutboundDelivery(client, accountId, threadId2, mediaSendReceiptIds(job, audioAsVoice), { jobId: job.id, state: job.state, serverIds });
1419
1649
  }
1420
1650
 
1421
1651
  // src/outbound-media.ts
1422
- async function sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice = false, idempotencyKey) {
1652
+ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
1423
1653
  const account = resolveWeChatAccount(cfg);
1424
1654
  if (!account?.native) throw new Error("No native daemon configured");
1425
1655
  const { client } = await ensureNativeHost(account.native);
1426
1656
  const key = idempotencyKey ?? randomUUID2();
1427
1657
  if (!mediaUrl) {
1428
1658
  if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
1429
- const send = await submitNativeText(client, account.native.accountId, to, text, key);
1430
- return `agent-wechat-native:${account.native.accountId}:${send.id}`;
1659
+ const send = await submitNativeText(client, account.native.accountId, to, text3, key);
1660
+ return nativeOutboundDelivery(
1661
+ client,
1662
+ account.native.accountId,
1663
+ to,
1664
+ send.localMessageId ? [send.localMessageId] : [],
1665
+ { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
1666
+ );
1431
1667
  }
1432
1668
  const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
1433
- if (text) await submitNativeText(client, account.native.accountId, to, text, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
1669
+ if (text3) await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
1434
1670
  return result;
1435
1671
  }
1436
1672
 
@@ -1458,9 +1694,16 @@ var NativeConsumerState = class {
1458
1694
  for (const [id2, item] of Object.entries(parsed.items)) {
1459
1695
  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");
1460
1696
  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
+ if (item.revision != null && (typeof item.revision !== "string" || !item.revision || item.revision.length > 64)) throw Error("Invalid native custody revision; state was preserved");
1698
+ item.revision ??= randomUUID3();
1461
1699
  if (item.groupContext && (!item.cursor || item.origin !== "live" || item.state !== "ignored" || item.message.direction !== "incoming" || !item.message.threadId.endsWith("@chatroom"))) throw new Error("Invalid native buffered group context; state was preserved");
1462
1700
  if (item.state === "dispatching") item.state = "needs_review";
1463
1701
  }
1702
+ if (parsed.recalls != null && (!parsed.recalls || typeof parsed.recalls !== "object" || Array.isArray(parsed.recalls))) throw Error("Invalid native recall state; state was preserved");
1703
+ for (const [key, recall] of Object.entries(parsed.recalls ?? {})) {
1704
+ validateNativeRecall(recall);
1705
+ if (key !== nativeRecallIdentity(recall.threadId, recall.serverId)) throw Error("Invalid native recall identity; state was preserved");
1706
+ }
1464
1707
  this.value = parsed;
1465
1708
  this.persist();
1466
1709
  }
@@ -1468,12 +1711,22 @@ var NativeConsumerState = class {
1468
1711
  get cursor() {
1469
1712
  return this.value.cursor;
1470
1713
  }
1714
+ get accountId() {
1715
+ return this.value.accountId;
1716
+ }
1471
1717
  items() {
1472
1718
  return Object.values(this.value.items);
1473
1719
  }
1474
1720
  get(messageId) {
1475
1721
  return this.value.items[messageId];
1476
1722
  }
1723
+ isRecalled(message) {
1724
+ return !!nativeRecallInfo(message) || !!(message.serverId && this.value.recalls?.[nativeRecallIdentity(message.threadId, message.serverId)]);
1725
+ }
1726
+ isCurrent(message, revision) {
1727
+ const current = this.get(message.id);
1728
+ return !!current && (revision === void 0 || current.revision === revision) && !this.isRecalled(message) && JSON.stringify(current.message) === JSON.stringify(message);
1729
+ }
1477
1730
  persist() {
1478
1731
  const directory = dirname2(this.file);
1479
1732
  mkdirSync(directory, { recursive: true, mode: 448 });
@@ -1517,42 +1770,129 @@ var NativeConsumerState = class {
1517
1770
  accept(cursor, message, origin = "unknown") {
1518
1771
  if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw new Error("Native journal cursor cannot move backward");
1519
1772
  if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
1773
+ let replaced = false;
1520
1774
  this.mutate(() => {
1521
- if (message && !this.value.items[message.id]) {
1775
+ if (message?.serverId && nativeRecallInfo(message)) this.rememberRecall({ messageId: message.id, threadId: message.threadId, serverId: message.serverId, ...nativeRecallInfo(message) });
1776
+ if (message?.serverId) {
1777
+ const recall = this.value.recalls?.[nativeRecallIdentity(message.threadId, message.serverId)];
1778
+ if (recall) message = recalledNativeMessage(message, recall);
1779
+ }
1780
+ const existing = message ? this.value.items[message.id] : void 0;
1781
+ replaced = !!message && !nativeRecallInfo(message) && !!existing && (existing.message.threadId !== message.threadId || existing.message.serverId !== message.serverId);
1782
+ if (message && (!existing || replaced)) {
1522
1783
  const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && !item.groupContext && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
1523
1784
  for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
1524
- if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1525
- this.value.items[message.id] = { message, origin, state: "received", cursor };
1785
+ if (!existing && Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1786
+ if (replaced) {
1787
+ for (const item of this.items()) {
1788
+ const manifest = item.dispatchManifest;
1789
+ if (!manifest || ![
1790
+ ...manifest.messageIds,
1791
+ ...(manifest.historyInputs ?? []).map((input) => input.id),
1792
+ ...manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
1793
+ ].includes(message.id)) continue;
1794
+ if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
1795
+ item.dispatchManifest = void 0;
1796
+ item.manifestHash = void 0;
1797
+ item.readPending = false;
1798
+ }
1799
+ }
1800
+ this.value.items[message.id] = { message, revision: randomUUID3(), origin, state: nativeRecallInfo(message) ? "ignored" : "received", cursor };
1526
1801
  }
1527
1802
  this.value.cursor = cursor;
1528
1803
  });
1804
+ return replaced;
1805
+ }
1806
+ /** Persist invalidation and the event cursor together, before consumer ACK. */
1807
+ acceptRecall(cursor, recall) {
1808
+ validateNativeRecall(recall);
1809
+ if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw Error("Native journal cursor cannot move backward");
1810
+ let affected = [];
1811
+ this.mutate(() => {
1812
+ affected = this.rememberRecall(recall);
1813
+ this.value.cursor = cursor;
1814
+ });
1815
+ return affected;
1529
1816
  }
1530
- update(messageId, update) {
1817
+ rememberRecall(recall) {
1818
+ const hinted = recall.messageId ? this.get(recall.messageId)?.message : void 0;
1819
+ if (hinted && (hinted.threadId !== recall.threadId || hinted.serverId !== recall.serverId)) {
1820
+ const { messageId: _stale, ...exact } = recall;
1821
+ recall = exact;
1822
+ }
1823
+ const key = nativeRecallIdentity(recall.threadId, recall.serverId);
1824
+ 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
+ };
1832
+ const affected = /* @__PURE__ */ new Set();
1833
+ for (const item of this.items()) {
1834
+ if (item.message.threadId !== recall.threadId) continue;
1835
+ const target = item.message.serverId === recall.serverId;
1836
+ const quote = item.message.quote?.serverId === recall.serverId;
1837
+ if (!target && !quote) continue;
1838
+ affected.add(item.message.id);
1839
+ item.message = target ? recalledNativeMessage(item.message, recall) : { ...item.message, quote: void 0 };
1840
+ if (target) item.groupContext = false;
1841
+ item.readPending = false;
1842
+ if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
1843
+ else if (target && item.state === "received") item.state = "ignored";
1844
+ item.invalidatedByRecall = true;
1845
+ item.dispatchManifest = void 0;
1846
+ item.manifestHash = void 0;
1847
+ }
1848
+ for (const item of this.items()) {
1849
+ const manifest = item.dispatchManifest;
1850
+ if (item.message.threadId !== recall.threadId || !manifest) continue;
1851
+ const references = [
1852
+ ...manifest.messageIds,
1853
+ ...(manifest.historyInputs ?? []).map((input) => input.id),
1854
+ ...manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
1855
+ ];
1856
+ if (!references.some((id2) => affected.has(id2) || id2 === recall.messageId)) continue;
1857
+ affected.add(item.message.id);
1858
+ item.invalidatedByRecall = true;
1859
+ item.readPending = false;
1860
+ if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
1861
+ item.dispatchManifest = void 0;
1862
+ item.manifestHash = void 0;
1863
+ }
1864
+ return [...affected];
1865
+ }
1866
+ update(messageId, update, revision) {
1531
1867
  const item = this.value.items[messageId];
1868
+ if (revision !== void 0 && item?.revision !== revision) return false;
1532
1869
  if (!item) throw new Error("Unknown native custody message");
1533
1870
  this.mutate(() => Object.assign(this.value.items[messageId], update));
1871
+ return true;
1534
1872
  }
1535
1873
  groupHistory(threadId2, beforeId, limit) {
1536
1874
  const before = this.value.items[beforeId]?.cursor;
1537
1875
  if (!before || limit <= 0) return [];
1538
1876
  return this.items().filter((item) => item.groupContext && item.state === "ignored" && item.origin === "live" && item.message.threadId === threadId2 && item.cursor && BigInt(item.cursor) < BigInt(before)).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : BigInt(a.cursor) > BigInt(b.cursor) ? 1 : 0).slice(-limit);
1539
1877
  }
1540
- bufferGroupMessage(messageId, limit) {
1878
+ bufferGroupMessage(messageId, limit, revision) {
1541
1879
  const item = this.value.items[messageId];
1880
+ if (revision !== void 0 && item?.revision !== revision) return false;
1542
1881
  if (!item || item.state !== "received" || item.origin !== "live" || item.message.direction !== "incoming" || !item.cursor || !item.message.threadId.endsWith("@chatroom") || !Number.isInteger(limit) || limit < 0 || limit > 200) throw new Error("Invalid native group history admission");
1543
1882
  this.mutate(() => {
1544
1883
  Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
1545
1884
  const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
1546
- const thread = history.filter((value) => value.message.threadId === item.message.threadId);
1547
- for (const value of thread.slice(0, Math.max(0, thread.length - limit))) value.groupContext = false;
1885
+ const thread3 = history.filter((value) => value.message.threadId === item.message.threadId);
1886
+ for (const value of thread3.slice(0, Math.max(0, thread3.length - limit))) value.groupContext = false;
1548
1887
  const retained = history.filter((value) => value.groupContext);
1549
1888
  for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
1550
1889
  });
1890
+ return true;
1551
1891
  }
1552
1892
  beginDispatch(messageId, dispatchManifest, historyIds = []) {
1553
- if (this.value.items[messageId]?.state !== "received" || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
1893
+ if (this.value.items[messageId]?.state !== "received" || this.isRecalled(this.value.items[messageId].message) || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
1554
1894
  this.mutate(() => {
1555
- Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
1895
+ Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest, invalidatedByRecall: false });
1556
1896
  for (const id2 of historyIds) this.value.items[id2].groupContext = false;
1557
1897
  });
1558
1898
  }
@@ -1565,6 +1905,7 @@ var NativeConsumerState = class {
1565
1905
  if (!item || item.state !== "dispatching") throw new Error("Prepared input manifest is not in dispatch custody");
1566
1906
  return item;
1567
1907
  });
1908
+ const revisions = items.map((item) => item.revision);
1568
1909
  if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
1569
1910
  const manifestHash = createHash6("sha256").update(JSON.stringify({
1570
1911
  accountId: this.value.accountId,
@@ -1575,8 +1916,15 @@ var NativeConsumerState = class {
1575
1916
  const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
1576
1917
  if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
1577
1918
  this.mutate(() => {
1578
- for (const id2 of exact) {
1919
+ const currentHash = createHash6("sha256").update(JSON.stringify({
1920
+ accountId: this.value.accountId,
1921
+ messageIds: exact,
1922
+ dispatchManifests: exact.map((id2) => this.value.items[id2]?.dispatchManifest ?? null)
1923
+ })).digest("hex");
1924
+ for (const [index, id2] of exact.entries()) {
1579
1925
  const item = this.value.items[id2];
1926
+ if (!item || item.invalidatedByRecall || this.isRecalled(item.message)) throw Error("Native prepared input was invalidated by a recall");
1927
+ if (item.revision !== revisions[index] || currentHash !== manifestHash) throw Error("Native prepared input source identity changed");
1580
1928
  if (item.runId && item.runId !== runId) throw new Error("Native prepared manifest was assigned to another run; operator review required");
1581
1929
  Object.assign(item, { state: "handed_off", runId, manifestHash, readPending: readPolicy === "on_model_dispatch", handoffUnconfirmed: false });
1582
1930
  }
@@ -1595,6 +1943,9 @@ var digest = (value) => createHash7("sha256").update(value).digest("hex");
1595
1943
  function prune() {
1596
1944
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1597
1945
  }
1946
+ function nativeSourceMessageSid(message) {
1947
+ return `wechat-native:${message.accountId}:${message.id}:${digest(JSON.stringify([message.threadId, message.serverId ?? null]))}`;
1948
+ }
1598
1949
  function nativeDispatchManifest(params) {
1599
1950
  const history = params.history ?? [];
1600
1951
  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");
@@ -1602,7 +1953,7 @@ function nativeDispatchManifest(params) {
1602
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");
1603
1954
  return Object.freeze({ ...asset });
1604
1955
  }));
1605
- const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
1956
+ const messageSid = nativeSourceMessageSid(params.message);
1606
1957
  const manifest = {
1607
1958
  schemaVersion: 2,
1608
1959
  runId: randomUUID4(),
@@ -1627,14 +1978,15 @@ function nativeDispatchManifest(params) {
1627
1978
  params.message.mentions,
1628
1979
  params.message.serverId,
1629
1980
  params.message.appMessageType,
1630
- params.message.quote
1981
+ params.message.quote,
1982
+ params.message.link
1631
1983
  ])),
1632
1984
  role: "trigger"
1633
1985
  })]),
1634
1986
  ...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
1635
1987
  id: message.id,
1636
1988
  version: message.version ?? "",
1637
- contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId, message.appMessageType, message.quote])),
1989
+ contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId, message.appMessageType, message.quote, message.link])),
1638
1990
  role: "history"
1639
1991
  }))) } : {},
1640
1992
  assets,
@@ -1650,30 +2002,40 @@ function trackNativePreparedInput(manifest, record) {
1650
2002
  if (!enabled || pending.size >= 1024) return;
1651
2003
  pending.set(manifest.runId, { manifest, record, expires: Date.now() + 15 * 60 * 1e3 });
1652
2004
  }
2005
+ function invalidateNativePreparedInputs(accountId, messageIds) {
2006
+ const ids = new Set(messageIds);
2007
+ for (const [runId, item] of pending) {
2008
+ if (item.manifest.accountId === accountId && [
2009
+ ...item.manifest.messageIds,
2010
+ ...(item.manifest.historyInputs ?? []).map((input) => input.id),
2011
+ ...item.manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
2012
+ ].some((id2) => ids.has(id2))) pending.delete(runId);
2013
+ }
2014
+ }
1653
2015
  function promptMatches(prompt, manifest) {
1654
2016
  if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 1024 * 1024) return false;
1655
- const text = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
1656
- const candidates = [text];
2017
+ const text3 = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
2018
+ const candidates = [text3];
1657
2019
  const sender = `Conversation info: \u27E6openclaw:ctx\u27E7
1658
2020
  \`\`\`json
1659
2021
  ${JSON.stringify({ sender: { id: manifest.senderId } })}
1660
2022
  \`\`\`
1661
2023
 
1662
2024
  `;
1663
- if (manifest.threadId.endsWith("@chatroom") && text.startsWith(sender)) candidates.push(text.slice(sender.length));
1664
- return candidates.some((text2) => normalizedPromptMatches(text2, manifest));
2025
+ if (manifest.threadId.endsWith("@chatroom") && text3.startsWith(sender)) candidates.push(text3.slice(sender.length));
2026
+ return candidates.some((text4) => normalizedPromptMatches(text4, manifest));
1665
2027
  }
1666
- function normalizedPromptMatches(text, manifest) {
2028
+ function normalizedPromptMatches(text3, manifest) {
1667
2029
  const runtimeAssets = manifest.assets.filter((asset) => asset.runtimeMedia);
1668
2030
  if (runtimeAssets.length) {
1669
2031
  if (runtimeAssets.length !== 1) return false;
1670
2032
  const asset = runtimeAssets[0];
1671
- const header = /^\[media attached: ([^\r\n]+) \(([^)\r\n]+)\) \| (media:\/\/inbound\/[^\]\r\n]+)\]\n/.exec(text);
2033
+ const header = /^\[media attached: ([^\r\n]+) \(([^)\r\n]+)\) \| (media:\/\/inbound\/[^\]\r\n]+)\]\n/.exec(text3);
1672
2034
  if (!header || !isAbsolute3(header[1]) || basename2(header[1]) !== `input-${basename2(asset.path)}` || header[2] !== asset.mime || header[3] !== `media://inbound/${basename2(asset.path)}`) return false;
1673
- text = text.slice(header[0].length);
1674
- if (asset.kind === "video") text = text.replace(/\n\n\[Video attachment not analyzed: video understanding is disabled\]$/, "");
2035
+ text3 = text3.slice(header[0].length);
2036
+ if (asset.kind === "video") text3 = text3.replace(/\n\n\[Video attachment not analyzed: video understanding is disabled\]$/, "");
1675
2037
  }
1676
- return digest(text) === manifest.inputBodySha256;
2038
+ return digest(text3) === manifest.inputBodySha256;
1677
2039
  }
1678
2040
  function currentMessageFacts(messages) {
1679
2041
  if (!Array.isArray(messages) || messages.length > 1e3) return [];
@@ -1734,8 +2096,8 @@ function observeNativeAgentEnd(event, ctx) {
1734
2096
  const blocks = typeof user.content === "string" ? [{ type: "text", text: user.content }] : user.content;
1735
2097
  if (!Array.isArray(blocks) || blocks.length > 32) return;
1736
2098
  if (blocks.some((block) => !["text", "image"].includes(block?.type) || block.type === "text" && typeof block.text !== "string")) return;
1737
- const text = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
1738
- if (!promptMatches(text, m)) return;
2099
+ const text3 = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
2100
+ if (!promptMatches(text3, m)) return;
1739
2101
  const images = blocks.filter((block) => block?.type === "image");
1740
2102
  const expectedImages = m.assets.filter((asset) => asset.role === "model_image");
1741
2103
  if (images.length !== expectedImages.length) return;
@@ -1745,7 +2107,7 @@ function observeNativeAgentEnd(event, ctx) {
1745
2107
  const bytes = Buffer.from(image.data, "base64");
1746
2108
  if (bytes.length !== asset.byteLength || digest(bytes) !== asset.sha256) return;
1747
2109
  }
1748
- if (m.assets.some((asset) => asset.role === "file_reference" && !text.includes(asset.path))) return;
2110
+ if (m.assets.some((asset) => asset.role === "file_reference" && !text3.includes(asset.path))) return;
1749
2111
  item.record(m.runId);
1750
2112
  pending.delete(m.runId);
1751
2113
  }
@@ -1860,22 +2222,22 @@ async function projectNativeRead(client, accountId, state) {
1860
2222
  for (const threadId2 of threads) {
1861
2223
  for (let attempt = 0; attempt < 2; attempt++) {
1862
2224
  let cursor;
1863
- let thread;
2225
+ let thread3;
1864
2226
  const seen = /* @__PURE__ */ new Set();
1865
2227
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
1866
2228
  const page = await client.threads(accountId, cursor);
1867
- thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
1868
- if (thread || !page.nextCursor) break;
2229
+ thread3 = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
2230
+ if (thread3 || !page.nextCursor) break;
1869
2231
  if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
1870
2232
  seen.add(page.nextCursor);
1871
2233
  cursor = page.nextCursor;
1872
2234
  }
1873
- if (!thread) break;
2235
+ if (!thread3) break;
1874
2236
  try {
1875
- const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
2237
+ const receipt = await client.markRead(accountId, thread3.id, thread3.readStateVersion);
1876
2238
  const notification = receipt?.phoneNotification?.state;
1877
2239
  const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
1878
- for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState });
2240
+ for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState }, item.revision);
1879
2241
  break;
1880
2242
  } catch (error) {
1881
2243
  if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
@@ -1884,13 +2246,72 @@ async function projectNativeRead(client, accountId, state) {
1884
2246
  }
1885
2247
  }
1886
2248
  }
2249
+ var NativeInputInvalidated = class extends Error {
2250
+ };
2251
+ async function acceptNativeEvent(client, accountId, state, event) {
2252
+ if (state.accountId !== accountId) throw Error("Native event crosses its custody account boundary");
2253
+ if (event.kind === "message.recalled") {
2254
+ const affected = state.acceptRecall(event.cursor, event.data);
2255
+ invalidateNativePreparedInputs(accountId, affected);
2256
+ return void 0;
2257
+ }
2258
+ let message;
2259
+ if (event.kind === "message.committed") {
2260
+ if (typeof event.data.messageId !== "string") throw Error("Native committed message identity is missing; cursor retained");
2261
+ message = await client.message(accountId, event.data.messageId);
2262
+ if (message.id !== event.data.messageId || message.accountId !== accountId || message.threadId !== event.data.threadId) throw Error("Native committed message identity mismatch");
2263
+ }
2264
+ const replaced = state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
2265
+ if (replaced && message) invalidateNativePreparedInputs(accountId, [message.id]);
2266
+ return message;
2267
+ }
2268
+ async function recheckNativeInputs(client, state, messages) {
2269
+ for (const message of messages) {
2270
+ if (!state.isCurrent(message)) throw new NativeInputInvalidated();
2271
+ const current = await client.message(message.accountId, message.id);
2272
+ if (current.accountId !== message.accountId || current.threadId !== message.threadId || current.id !== message.id || current.serverId !== message.serverId) throw Error("Native input identity changed; delivery held for review");
2273
+ const recall = nativeRecallInfo(current);
2274
+ if (recall && current.serverId) {
2275
+ const affected = state.acceptRecall(state.cursor, { ...recall, messageId: current.id, threadId: current.threadId, serverId: current.serverId });
2276
+ invalidateNativePreparedInputs(message.accountId, [current.id, ...affected]);
2277
+ throw new NativeInputInvalidated();
2278
+ }
2279
+ 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)) {
2283
+ const affected = state.acceptRecall(state.cursor, {
2284
+ threadId: message.threadId,
2285
+ serverId: message.quote.serverId,
2286
+ observedAt: quote.message && nativeRecallInfo(quote.message)?.observedAt || (/* @__PURE__ */ new Date()).toISOString()
2287
+ });
2288
+ invalidateNativePreparedInputs(message.accountId, affected);
2289
+ throw new NativeInputInvalidated();
2290
+ }
2291
+ }
2292
+ if (!state.isCurrent(message)) throw new NativeInputInvalidated();
2293
+ }
2294
+ }
1887
2295
  async function dispatchNativeMessage(params) {
2296
+ const revision = params.state.get(params.message.id)?.revision;
2297
+ try {
2298
+ return await dispatchNativeMessageInput(params, revision);
2299
+ } catch (error) {
2300
+ const item = params.state.get(params.message.id);
2301
+ if (params.state.isRecalled(params.message)) return "ignored";
2302
+ if (error instanceof NativeInputInvalidated && item?.state === "received") return "pending";
2303
+ if (item) params.state.update(params.message.id, { state: "needs_review" }, revision);
2304
+ return "needs_review";
2305
+ }
2306
+ }
2307
+ async function dispatchNativeMessageInput(params, revision) {
1888
2308
  const { client, state, account, message, cfg, core } = params;
1889
2309
  const readPolicy = account.native?.readPolicy ?? "on_inbound";
1890
2310
  const custody = state.get(message.id);
1891
- if (!custody || custody.state !== "received") return "needs_review";
2311
+ if (state.isRecalled(message)) return "ignored";
2312
+ if (!custody || custody.state !== "received" || !state.isCurrent(message, revision)) return "needs_review";
1892
2313
  if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
1893
- state.update(message.id, { state: "ignored" });
2314
+ state.update(message.id, { state: "ignored" }, revision);
1894
2315
  return "ignored";
1895
2316
  }
1896
2317
  const isGroup = message.threadId.endsWith("@chatroom");
@@ -1925,32 +2346,50 @@ async function dispatchNativeMessage(params) {
1925
2346
  const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
1926
2347
  if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
1927
2348
  if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
1928
- state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
2349
+ state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg), revision);
1929
2350
  return "ignored";
1930
2351
  }
1931
- state.update(message.id, { state: "ignored" });
2352
+ state.update(message.id, { state: "ignored" }, revision);
1932
2353
  return "ignored";
1933
2354
  }
1934
2355
  let preparedMedia;
1935
2356
  const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
1936
- try {
1937
- preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg, allowQuotedMessage });
1938
- } catch {
1939
- state.update(message.id, { state: "needs_review" });
1940
- return "needs_review";
1941
- }
2357
+ const assertCurrent = () => {
2358
+ params.signal?.throwIfAborted();
2359
+ if (!state.isCurrent(message, revision)) throw new NativeInputInvalidated();
2360
+ };
2361
+ const recheck = async (inputs) => {
2362
+ assertCurrent();
2363
+ await recheckNativeInputs(client, state, inputs);
2364
+ assertCurrent();
2365
+ };
2366
+ await recheck([message]);
2367
+ preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg, allowQuotedMessage, assertCurrent });
1942
2368
  if (preparedMedia.pending) return "pending";
1943
2369
  preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
2370
+ await recheck([message]);
1944
2371
  const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
1945
2372
  const admitted = [];
1946
2373
  for (const item of buffered) {
1947
2374
  if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
1948
2375
  }
1949
2376
  const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
1950
- await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message: historical, core, cfg, allowQuotedMessage }),
2377
+ await (params.prepareAttachment ?? prepareNativeAttachment)({
2378
+ client,
2379
+ account,
2380
+ message: historical,
2381
+ core,
2382
+ cfg,
2383
+ allowQuotedMessage,
2384
+ assertCurrent: () => {
2385
+ assertCurrent();
2386
+ if (!state.isCurrent(historical)) throw new NativeInputInvalidated();
2387
+ }
2388
+ }),
1951
2389
  core,
1952
2390
  cfg
1953
2391
  ));
2392
+ await recheck([message, ...admitted]);
1954
2393
  const context = buildNativeContext(history, message, preparedMedia);
1955
2394
  const inputBody = context.inputBody;
1956
2395
  const route = core.channel.routing.resolveAgentRoute({
@@ -1980,7 +2419,7 @@ async function dispatchNativeMessage(params) {
1980
2419
  SenderId: message.senderId,
1981
2420
  Provider: "agent-wechat",
1982
2421
  Surface: "agent-wechat",
1983
- MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
2422
+ MessageSid: nativeSourceMessageSid(message),
1984
2423
  WasMentioned: isGroup ? wasMentioned : void 0,
1985
2424
  Attachments: context.attachments,
1986
2425
  InboundHistory: context.history,
@@ -1990,6 +2429,7 @@ async function dispatchNativeMessage(params) {
1990
2429
  ...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
1991
2430
  });
1992
2431
  await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
2432
+ await recheck([message, ...admitted]);
1993
2433
  const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
1994
2434
  const { onModelSelected, ...prefixOptions } = pipeline;
1995
2435
  const dispatchManifest = nativeDispatchManifest({
@@ -2002,9 +2442,11 @@ async function dispatchNativeMessage(params) {
2002
2442
  assets: context.assets,
2003
2443
  history: admitted
2004
2444
  });
2445
+ assertCurrent();
2005
2446
  state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
2006
- const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
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;
2007
2448
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2449
+ const beforeSend = () => recheck([message, ...admitted]);
2008
2450
  let failed = false;
2009
2451
  let delivery = 0;
2010
2452
  try {
@@ -2014,15 +2456,19 @@ async function dispatchNativeMessage(params) {
2014
2456
  dispatcherOptions: {
2015
2457
  ...prefixOptions,
2016
2458
  deliver: async (payload) => {
2459
+ params.signal?.throwIfAborted();
2460
+ if (state.get(message.id)?.invalidatedByRecall) return;
2461
+ await beforeSend();
2017
2462
  const urls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
2018
2463
  if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
2019
2464
  if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
2020
- for (const url of urls) {
2021
- const key = createHash8("sha256").update(`${account.native.accountId}\0${message.id}\0${delivery++}`).digest("hex");
2022
- await sendNativeAttachment(client, account, message.threadId, url, payload.audioAsVoice === true, key);
2465
+ for (const url2 of urls) {
2466
+ const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2467
+ await sendNativeAttachment(client, account, message.threadId, url2, payload.audioAsVoice === true, key, beforeSend);
2023
2468
  }
2024
2469
  if (payload.text) {
2025
- const key = createHash8("sha256").update(`${account.native.accountId}\0${message.id}\0${delivery++}`).digest("hex");
2470
+ await beforeSend();
2471
+ const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
2026
2472
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
2027
2473
  }
2028
2474
  if (urls.length || payload.text) params.onDelivered?.();
@@ -2037,18 +2483,34 @@ async function dispatchNativeMessage(params) {
2037
2483
  // and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
2038
2484
  replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
2039
2485
  });
2486
+ params.signal?.throwIfAborted();
2487
+ if (state.get(message.id)?.revision !== revision || state.get(message.id)?.dispatchManifest?.runId !== dispatchManifest.runId) return state.isRecalled(message) ? "ignored" : "needs_review";
2488
+ if (state.get(message.id)?.invalidatedByRecall) return state.isRecalled(message) ? "ignored" : "needs_review";
2040
2489
  const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
2041
2490
  state.update(message.id, {
2042
2491
  state: failed ? "needs_review" : "completed",
2043
2492
  readPending,
2044
2493
  handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
2045
- });
2494
+ }, revision);
2046
2495
  return failed ? "needs_review" : "completed";
2047
2496
  } catch {
2048
- state.update(message.id, { state: "needs_review" });
2497
+ if (state.isRecalled(message)) return "ignored";
2498
+ state.update(message.id, { state: "needs_review" }, revision);
2049
2499
  return "needs_review";
2050
2500
  }
2051
2501
  }
2502
+ async function synchronizeNativeEvents(client, accountId, state) {
2503
+ for (let index = 0; index < 100; index++) {
2504
+ const page = await client.events(accountId, state.cursor, 200);
2505
+ if (!page.items.length) return;
2506
+ const before = state.cursor;
2507
+ for (const event of page.items) await acceptNativeEvent(client, accountId, state, event);
2508
+ if (state.cursor === before) throw Error("Native recovery event cursor did not advance");
2509
+ await client.ack(accountId, "openclaw", state.cursor);
2510
+ if (page.items.length < 200) return;
2511
+ }
2512
+ throw Error("Native recovery backlog remains; no model input was dispatched");
2513
+ }
2052
2514
  function startNativeReadPump(task, failed, intervalMs = 5e3) {
2053
2515
  let active2 = false, stopped = false;
2054
2516
  const timer = setInterval(() => {
@@ -2094,6 +2556,7 @@ async function startNativeMonitor(opts) {
2094
2556
  message: item.message,
2095
2557
  cfg,
2096
2558
  core,
2559
+ signal: opts.abortSignal,
2097
2560
  onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
2098
2561
  });
2099
2562
  }
@@ -2114,9 +2577,24 @@ async function startNativeMonitor(opts) {
2114
2577
  handoffUnconfirmed
2115
2578
  });
2116
2579
  };
2117
- await process2();
2580
+ let processing;
2581
+ let processAgain = false;
2582
+ let intakeReady = false;
2583
+ const scheduleProcess = () => {
2584
+ processAgain = true;
2585
+ if (processing || opts.abortSignal.aborted || !intakeReady) return;
2586
+ processing = (async () => {
2587
+ do {
2588
+ processAgain = false;
2589
+ await process2();
2590
+ } while (processAgain && !opts.abortSignal.aborted);
2591
+ })().catch(() => opts.log?.error?.("Native input processing was interrupted; durable custody was retained")).finally(() => {
2592
+ processing = void 0;
2593
+ });
2594
+ };
2118
2595
  const stopReadPump = startNativeReadPump(async () => {
2119
2596
  await flushRead();
2597
+ scheduleProcess();
2120
2598
  if (!opts.abortSignal.aborted) opts.setStatus({
2121
2599
  accountId: opts.account.accountId,
2122
2600
  handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
@@ -2126,18 +2604,15 @@ async function startNativeMonitor(opts) {
2126
2604
  while (!opts.abortSignal.aborted) {
2127
2605
  try {
2128
2606
  if (state.cursor !== "0") await host2.client.ack(native.accountId, "openclaw", state.cursor);
2129
- await process2();
2607
+ intakeReady = false;
2608
+ await synchronizeNativeEvents(host2.client, native.accountId, state);
2609
+ intakeReady = true;
2610
+ scheduleProcess();
2130
2611
  for await (const event of host2.client.watch(native.accountId, state.cursor, opts.abortSignal)) {
2131
- let message;
2132
- if (event.kind === "message.committed") {
2133
- if (typeof event.data.messageId !== "string") throw new Error("Native committed message identity is missing; cursor retained");
2134
- message = await host2.client.message(native.accountId, event.data.messageId);
2135
- if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
2136
- }
2137
- state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
2138
- if (message?.direction === "incoming" && event.data.origin === "live") opts.setStatus({ accountId: opts.account.accountId, lastInboundAt: Date.now() });
2612
+ const message = await acceptNativeEvent(host2.client, native.accountId, state, event);
2613
+ if (message?.direction === "incoming" && event.data.origin === "live" && !nativeRecallInfo(message)) opts.setStatus({ accountId: opts.account.accountId, lastInboundAt: Date.now() });
2139
2614
  await host2.client.ack(native.accountId, "openclaw", state.cursor);
2140
- await process2();
2615
+ scheduleProcess();
2141
2616
  backoff = 250;
2142
2617
  }
2143
2618
  if (!opts.abortSignal.aborted) throw new Error("Native event stream closed");
@@ -2207,11 +2682,17 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
2207
2682
  }
2208
2683
  return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
2209
2684
  }
2210
- async function sendNativeText(account, threadId2, text, key = randomUUID5()) {
2685
+ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
2211
2686
  const native = account.native;
2212
2687
  const { client } = await ensureNativeHost(native);
2213
- const send = await submitNativeText(client, native.accountId, threadId2, text, key);
2214
- return `agent-wechat-native:${native.accountId}:${send.id}`;
2688
+ const send = await submitNativeText(client, native.accountId, threadId2, text3, key);
2689
+ return nativeOutboundDelivery(
2690
+ client,
2691
+ native.accountId,
2692
+ threadId2,
2693
+ send.localMessageId ? [send.localMessageId] : [],
2694
+ { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
2695
+ );
2215
2696
  }
2216
2697
  function createNativeLoginTool(account) {
2217
2698
  return {
@@ -2238,10 +2719,10 @@ function createNativeLoginTool(account) {
2238
2719
  }
2239
2720
 
2240
2721
  // src/channel.ts
2241
- async function sendWeChatText(cfg, to, text) {
2722
+ async function sendWeChatText(cfg, to, text3) {
2242
2723
  const account = resolveWeChatAccount(cfg);
2243
2724
  if (!account?.serverUrl) throw new Error("No native daemon configured");
2244
- return sendNativeText(account, to, text);
2725
+ return sendNativeText(account, to, text3);
2245
2726
  }
2246
2727
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2247
2728
  id: "agent-wechat",
@@ -2251,13 +2732,13 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2251
2732
  messageSendingHooks: true
2252
2733
  },
2253
2734
  outbound: {
2254
- sendText: async ({ cfg, to, text }) => ({
2735
+ sendText: async ({ cfg, to, text: text3 }) => ({
2255
2736
  channel: "agent-wechat",
2256
- messageId: await sendWeChatText(cfg, to, text)
2737
+ ...await sendWeChatText(cfg, to, text3)
2257
2738
  }),
2258
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2739
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2259
2740
  channel: "agent-wechat",
2260
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2741
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2261
2742
  })
2262
2743
  }
2263
2744
  });
@@ -2380,7 +2861,7 @@ var wechatPlugin = {
2380
2861
  },
2381
2862
  // ---- Mention adapter ----
2382
2863
  mentions: {
2383
- stripMentions: ({ text, ctx }) => normalizeWeChatCommandBody(text, {
2864
+ stripMentions: ({ text: text3, ctx }) => normalizeWeChatCommandBody(text3, {
2384
2865
  isGroup: ctx.ChatType === "group",
2385
2866
  wasMentioned: ctx.WasMentioned === true
2386
2867
  })
@@ -2399,13 +2880,13 @@ var wechatPlugin = {
2399
2880
  // ---- Outbound adapter for OpenClaw hosts using the outbound hook ----
2400
2881
  outbound: {
2401
2882
  deliveryMode: "direct",
2402
- sendText: async ({ cfg, to, text }) => ({
2883
+ sendText: async ({ cfg, to, text: text3 }) => ({
2403
2884
  channel: "agent-wechat",
2404
- messageId: await sendWeChatText(cfg, to, text)
2885
+ ...await sendWeChatText(cfg, to, text3)
2405
2886
  }),
2406
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2887
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2407
2888
  channel: "agent-wechat",
2408
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2889
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2409
2890
  })
2410
2891
  },
2411
2892
  message: wechatMessageAdapter,
@@ -2586,9 +3067,9 @@ function createNativeFacade(config, dependencies = {}) {
2586
3067
  try {
2587
3068
  const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
2588
3069
  const headers = new Headers();
2589
- for (const name of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
2590
- const value = req.headers[name];
2591
- if (typeof value === "string") headers.set(name, value);
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);
2592
3073
  }
2593
3074
  const length = req.headers["content-length"];
2594
3075
  if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
@@ -2612,9 +3093,9 @@ function createNativeFacade(config, dependencies = {}) {
2612
3093
  response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
2613
3094
  }
2614
3095
  res.statusCode = response.status;
2615
- for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
2616
- const value = response.headers.get(name);
2617
- if (value) res.setHeader(name, value);
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);
2618
3099
  }
2619
3100
  res.setHeader("x-content-type-options", "nosniff");
2620
3101
  const reader = response.body?.getReader();
@@ -2673,18 +3154,29 @@ function object(value) {
2673
3154
  if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
2674
3155
  return value;
2675
3156
  }
2676
- function string(value, name, max = 4096) {
2677
- if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name}`);
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}`);
2678
3159
  return value.trim();
2679
3160
  }
2680
3161
  function threadId(value) {
2681
3162
  return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
2682
3163
  }
2683
3164
  function messageResult(message, retrievalTool = "wechat_media") {
3165
+ if (nativeRecallInfo(message)) return {
3166
+ id: message.id,
3167
+ threadId: message.threadId,
3168
+ serverId: message.serverId,
3169
+ direction: message.direction,
3170
+ type: 1e4,
3171
+ body: "[Message recalled]",
3172
+ status: "recalled",
3173
+ recall: nativeRecallInfo(message)
3174
+ };
2684
3175
  const isQuote = message.type === 49 && message.appMessageType === 57;
2685
3176
  const isForward = message.type === 49 && message.appMessageType === 19;
2686
- const kind2 = isQuote ? "quote" : isForward ? "forwarded_history" : 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";
2687
- const body = message.type === 1 || isForward ? message.body ?? "" : isQuote ? `${message.body ?? ""}
3177
+ const isLink = message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link;
3178
+ 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 ?? ""}
2688
3180
  [Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
2689
3181
  return {
2690
3182
  id: message.id,
@@ -2695,6 +3187,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
2695
3187
  type: message.type,
2696
3188
  appMessageType: message.appMessageType,
2697
3189
  quote: message.quote,
3190
+ link: isLink ? message.link : void 0,
2698
3191
  createdAt: message.createdAt,
2699
3192
  body: body.slice(0, 4e3),
2700
3193
  bodyTruncated: body.length > 4e3,
@@ -2712,6 +3205,7 @@ function createLookupTools(context, dependencies, archive) {
2712
3205
  if (!currentConfig()) return [];
2713
3206
  const selected = resolveWeChatAccount(currentConfig());
2714
3207
  if (!selected?.native || !selected.enabled) return [];
3208
+ const configurationKey = nativeConfigurationKey(selected.native);
2715
3209
  if (!archive && context.messageChannel !== "agent-wechat") return [];
2716
3210
  if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
2717
3211
  if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
@@ -2733,9 +3227,9 @@ function createLookupTools(context, dependencies, archive) {
2733
3227
  if (!currentThread) return [];
2734
3228
  }
2735
3229
  const selectedId = selected.native.accountId;
2736
- const tool = (name, action, description, properties, required = []) => ({
2737
- name,
2738
- label: name.replaceAll("_", " "),
3230
+ const tool = (name2, action, description, properties, required = []) => ({
3231
+ name: name2,
3232
+ label: name2.replaceAll("_", " "),
2739
3233
  description,
2740
3234
  // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
2741
3235
  // public direct-only surface preserves actual image content for vision.
@@ -2748,7 +3242,7 @@ function createLookupTools(context, dependencies, archive) {
2748
3242
  context.assertInvocationCurrent?.();
2749
3243
  const cfg2 = currentConfig();
2750
3244
  const account2 = resolveWeChatAccount(cfg2);
2751
- if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId) throw Error("WeChat account configuration changed; start a new turn");
3245
+ 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");
2752
3246
  return { cfg: cfg2, account: account2, native: account2.native };
2753
3247
  };
2754
3248
  const { cfg, account, native } = guard();
@@ -2790,10 +3284,10 @@ function createLookupTools(context, dependencies, archive) {
2790
3284
  if (action === "threads") {
2791
3285
  const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
2792
3286
  const items = [];
2793
- for (const thread of page.items) {
2794
- if (isNativeTombstone(thread)) continue;
2795
- if (thread.accountId !== accountId) throw Error("Thread response crosses an account boundary");
2796
- items.push({ id: thread.id, peerId: thread.peerId, kind: thread.kind, displayName: thread.displayName, unreadCount: thread.unreadCount });
3287
+ for (const thread3 of page.items) {
3288
+ if (isNativeTombstone(thread3)) continue;
3289
+ if (thread3.accountId !== accountId) throw Error("Thread response crosses an account boundary");
3290
+ items.push({ id: thread3.id, peerId: thread3.peerId, kind: thread3.kind, displayName: thread3.displayName, unreadCount: thread3.unreadCount });
2797
3291
  }
2798
3292
  result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
2799
3293
  } else if (action === "message" || action === "media") {
@@ -2803,6 +3297,18 @@ function createLookupTools(context, dependencies, archive) {
2803
3297
  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");
2804
3298
  result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
2805
3299
  if (action === "media") {
3300
+ const checkRecall = async () => {
3301
+ guard();
3302
+ const latest = await client.message(accountId, messageId);
3303
+ guard();
3304
+ if (latest.id !== message.id || latest.accountId !== accountId || latest.threadId !== message.threadId || latest.serverId !== message.serverId) throw Error("Native media message identity changed");
3305
+ if (nativeRecallInfo(latest)) throw new NativeApiError(410, "message_recalled", false);
3306
+ 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);
3310
+ }
3311
+ };
2806
3312
  let prepared;
2807
3313
  try {
2808
3314
  prepared = await prepareNativeAttachment({
@@ -2819,6 +3325,7 @@ function createLookupTools(context, dependencies, archive) {
2819
3325
  allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
2820
3326
  beforeMediaAccess: async () => {
2821
3327
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
3328
+ await checkRecall();
2822
3329
  }
2823
3330
  });
2824
3331
  } catch (error) {
@@ -2827,7 +3334,7 @@ function createLookupTools(context, dependencies, archive) {
2827
3334
  const details2 = {
2828
3335
  messageId: message.id,
2829
3336
  threadId: message.threadId,
2830
- status: error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
3337
+ status: error.code === "message_recalled" ? "recalled" : error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
2831
3338
  retryable: error.retryable,
2832
3339
  reason: error.code
2833
3340
  };
@@ -2838,9 +3345,10 @@ function createLookupTools(context, dependencies, archive) {
2838
3345
  const details = {
2839
3346
  messageId: message.id,
2840
3347
  threadId: message.threadId,
2841
- status: prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
3348
+ status: prepared.reason === "message_recalled" ? "recalled" : prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
2842
3349
  attachment: prepared.attachment,
2843
3350
  quote: prepared.quote,
3351
+ link: ["message_recalled", "media_identity_unavailable", "media_source_changed"].includes(prepared.reason ?? "") ? void 0 : message.link,
2844
3352
  variant: prepared.variant,
2845
3353
  jobId: prepared.jobId,
2846
3354
  retryable: prepared.retryable ?? false,
@@ -2850,6 +3358,13 @@ function createLookupTools(context, dependencies, archive) {
2850
3358
  const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
2851
3359
  const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
2852
3360
  guard();
3361
+ try {
3362
+ await checkRecall();
3363
+ } catch (error) {
3364
+ if (!(error instanceof NativeApiError) || error.code !== "message_recalled") throw error;
3365
+ const details2 = { messageId: message.id, threadId: message.threadId, status: "recalled", reason: "message_recalled", retryable: false };
3366
+ return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
3367
+ }
2853
3368
  return output;
2854
3369
  }
2855
3370
  } else {
@@ -2898,6 +3413,257 @@ function registerNativeHistoryTools(api) {
2898
3413
  api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
2899
3414
  }
2900
3415
 
3416
+ // src/native-share-tool.ts
3417
+ import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress3 } from "openclaw/plugin-sdk/channel-ingress-runtime";
3418
+ var name = "wechat_send_share";
3419
+ function text(value, label, max, multiline = false) {
3420
+ if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > max || (multiline ? /[\u0000-\u0008\u000b-\u001f\u007f]/ : /[\u0000-\u001f\u007f]/).test(value)) throw Error(`Invalid ${label}`);
3421
+ return value;
3422
+ }
3423
+ function thread(value) {
3424
+ return text(text(value, "current conversation", 256).replace(/^(?:agent-)?wechat:(?:group:)?/i, "").trim(), "current conversation", 256);
3425
+ }
3426
+ function url(value, label) {
3427
+ const raw = text(value, label, 8192);
3428
+ const parsed = new URL(raw);
3429
+ if (!["https:", "http:"].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) throw Error(`Invalid ${label}`);
3430
+ return raw;
3431
+ }
3432
+ function createNativeShareTools(context, dependencies = {}) {
3433
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3434
+ if (!currentConfig()) return [];
3435
+ const selected = resolveWeChatAccount(currentConfig());
3436
+ if (!selected?.enabled || !selected.native || context.messageChannel !== "agent-wechat" || context.agentAccountId && context.agentAccountId !== selected.accountId || context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
3437
+ let conversation, requester;
3438
+ try {
3439
+ const native = context.nativeChannelId ? thread(context.nativeChannelId) : void 0;
3440
+ const delivery = context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.to ? thread(context.deliveryContext.to) : void 0;
3441
+ if (native && delivery && native !== delivery) return [];
3442
+ conversation = native ?? delivery;
3443
+ if (!conversation) return [];
3444
+ requester = text(context.requesterSenderId, "requester sender", 256);
3445
+ } catch {
3446
+ return [];
3447
+ }
3448
+ const configurationKey = nativeConfigurationKey(selected.native);
3449
+ const fields = ["title", "url", "description", "thumbnailUrl", "idempotencyKey"];
3450
+ return [{
3451
+ name,
3452
+ label: "Send WeChat share",
3453
+ description: "Send a URL share card to the current WeChat conversation, with an explicit title, URL, optional description and public thumbnail URL. Does not fetch or summarize the linked page. Use one idempotencyKey per intended share and reuse it after timeouts; never retry an uncertain send with a new key. Returns a durable send ID and state. Accepted means server acceptance; recipient rendering requires verification.",
3454
+ parameters: { type: "object", additionalProperties: false, required: ["title", "url", "idempotencyKey"], properties: {
3455
+ title: { type: "string", minLength: 1, maxLength: 16384 },
3456
+ url: { type: "string", minLength: 1, maxLength: 8192 },
3457
+ description: { type: "string", maxLength: 16384 },
3458
+ thumbnailUrl: { type: "string", minLength: 1, maxLength: 8192, description: "Public HTTP(S) image URL advertised as the card thumbnail; no upload or page scraping." },
3459
+ idempotencyKey: { type: "string", minLength: 1, maxLength: 200, description: "Stable unique key for this logical share. Reuse the same key and identical fields when inspecting/retrying this request." }
3460
+ } },
3461
+ execute: async (_callId, input, signal) => {
3462
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("Expected share arguments");
3463
+ const args = input;
3464
+ if (Object.keys(args).some((key2) => !fields.includes(key2))) throw Error("Share sending is restricted to the current conversation");
3465
+ const key = text(args.idempotencyKey, "idempotency key", 200);
3466
+ const request = {
3467
+ kind: "share",
3468
+ threadId: conversation,
3469
+ title: text(args.title, "title", 16384, true),
3470
+ url: url(args.url, "URL"),
3471
+ ...args.description !== void 0 ? { description: args.description === "" ? "" : text(args.description, "description", 16384, true) } : {},
3472
+ ...args.thumbnailUrl !== void 0 ? { thumbnailUrl: url(args.thumbnailUrl, "thumbnail URL") } : {}
3473
+ };
3474
+ const guard = () => {
3475
+ signal?.throwIfAborted();
3476
+ context.assertInvocationCurrent?.();
3477
+ const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
3478
+ if (!account?.enabled || !account.native || account.accountId !== selected.accountId || account.native.accountId !== selected.native.accountId || nativeConfigurationKey(account.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
3479
+ return { cfg, account, native: account.native };
3480
+ };
3481
+ const core = dependencies.core ?? getWeChatRuntime();
3482
+ const authorize = async () => {
3483
+ const { cfg, account } = guard();
3484
+ const result = await resolveWeChatIngress({
3485
+ resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress3,
3486
+ cfg,
3487
+ accountId: account.accountId,
3488
+ policy: resolveWeChatPolicyContext({ account, cfg, chatId: conversation }),
3489
+ isGroup: conversation.endsWith("@chatroom"),
3490
+ chatId: conversation,
3491
+ senderId: requester,
3492
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3493
+ });
3494
+ guard();
3495
+ if (result.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3496
+ };
3497
+ await authorize();
3498
+ const { native } = guard();
3499
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3500
+ await authorize();
3501
+ let intent;
3502
+ try {
3503
+ intent = await client.sendShare(native.accountId, request, key, signal);
3504
+ } catch (error) {
3505
+ if (!(error instanceof NativeApiError) || error.status >= 500 || error.code === "timeout") {
3506
+ const details2 = { threadId: conversation, idempotencyKey: key, state: "submission_unknown", note: "Inspect the send by this idempotency key. Do not resend with a new key." };
3507
+ return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2, isError: true };
3508
+ }
3509
+ throw error;
3510
+ }
3511
+ if (intent.accountId !== native.accountId || intent.destination !== conversation || !intent.id) throw Error("Share receipt crosses an account or conversation boundary; inspect the original idempotency key before another send");
3512
+ const delivery = intent.state === "accepted" && intent.localMessageId && intent.serverId ? await nativeOutboundDelivery(client, native.accountId, conversation, [intent.localMessageId], { sendId: intent.id, state: intent.state, serverIds: [intent.serverId] }) : void 0;
3513
+ const details = {
3514
+ id: intent.id,
3515
+ threadId: conversation,
3516
+ idempotencyKey: key,
3517
+ state: intent.state,
3518
+ serverId: intent.serverId,
3519
+ messageId: delivery?.messageId || void 0,
3520
+ receiptState: delivery?.meta.receiptState,
3521
+ reason: intent.errorCode,
3522
+ note: ["accepted", "sent", "server_accepted"].includes(intent.state) ? "Server accepted; recipient rendering is not confirmed." : "Inspect this send ID or reuse the identical request/key. Do not send again with a new key."
3523
+ };
3524
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details, isError: ["failed", "needs_review", "cancelled"].includes(intent.state) };
3525
+ }
3526
+ }];
3527
+ }
3528
+ function registerNativeShareTools(api) {
3529
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeShareTools(context) }, { names: [name] });
3530
+ }
3531
+
3532
+ // src/native-recall-tools.ts
3533
+ import { createHash as createHash9 } from "node:crypto";
3534
+ import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress4 } from "openclaw/plugin-sdk/channel-ingress-runtime";
3535
+ var names2 = ["wechat_recall"];
3536
+ function text2(value, label) {
3537
+ if (typeof value !== "string" || !value.trim() || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${label}`);
3538
+ return value.trim();
3539
+ }
3540
+ function thread2(value) {
3541
+ return text2(text2(value, "conversation").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "conversation");
3542
+ }
3543
+ function nativeRecallKey(accountId, threadId2, messageId, serverId) {
3544
+ return `openclaw-recall-${createHash9("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
3545
+ }
3546
+ function createNativeRecallTools(context, dependencies = {}) {
3547
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3548
+ if (!currentConfig()) return [];
3549
+ const selected = resolveWeChatAccount(currentConfig());
3550
+ if (!selected?.enabled || !selected.native || context.messageChannel !== "agent-wechat" || context.agentAccountId && context.agentAccountId !== selected.accountId || context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
3551
+ let conversation, requester;
3552
+ try {
3553
+ const nativeThread = context.nativeChannelId ? thread2(context.nativeChannelId) : void 0;
3554
+ const delivered = context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.to ? thread2(context.deliveryContext.to) : void 0;
3555
+ if (nativeThread && delivered && nativeThread !== delivered) return [];
3556
+ conversation = text2(nativeThread ?? delivered, "conversation");
3557
+ requester = text2(context.requesterSenderId, "requester");
3558
+ } catch {
3559
+ return [];
3560
+ }
3561
+ const accountId = selected.native.accountId;
3562
+ const configurationKey = nativeConfigurationKey(selected.native);
3563
+ return [{
3564
+ name: names2[0],
3565
+ label: "WeChat Recall",
3566
+ description: "Recall one own sent message in the current WeChat conversation by its exact opaque message ID from a verified send receipt or wechat_history, or inspect a retained recalled placeholder without another native mutation. The daemon checks eligibility. Returns the durable operation and confirmed, rejected, pending or uncertain result. Never invent IDs or resubmit an uncertain action with a new key; prior model input and completed actions cannot be erased.",
3567
+ parameters: { type: "object", additionalProperties: false, properties: {
3568
+ messageId: { type: "string", minLength: 1, maxLength: 4096, description: "Exact stored message ID in this conversation; send/job/operation IDs are not message IDs." },
3569
+ serverId: { type: "string", pattern: "^[1-9][0-9]{0,19}$", description: "Exact decimal server ID from the same verified receipt or history entry. The stored local message ID alone may be reused." }
3570
+ }, required: ["messageId", "serverId"] },
3571
+ execute: async (_callId, input, signal) => {
3572
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key2) => !["messageId", "serverId"].includes(key2))) throw Error("Recall is restricted to a message ID in the current conversation");
3573
+ const messageId = text2(input.messageId, "message ID");
3574
+ const serverId = text2(input.serverId, "server ID");
3575
+ if (!/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n) throw Error("Invalid exact server message ID");
3576
+ const guard = () => {
3577
+ signal?.throwIfAborted();
3578
+ context.assertInvocationCurrent?.();
3579
+ const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
3580
+ 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");
3581
+ return { cfg, account, native: account.native };
3582
+ };
3583
+ const allowed = async () => {
3584
+ const { cfg, account } = guard();
3585
+ const core = dependencies.core ?? getWeChatRuntime();
3586
+ const ingress = await resolveWeChatIngress({
3587
+ resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress4,
3588
+ cfg,
3589
+ accountId: account.accountId,
3590
+ policy: resolveWeChatPolicyContext({ account, cfg, chatId: conversation }),
3591
+ isGroup: conversation.endsWith("@chatroom"),
3592
+ chatId: conversation,
3593
+ senderId: requester,
3594
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3595
+ });
3596
+ guard();
3597
+ if (ingress.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3598
+ };
3599
+ await allowed();
3600
+ const { native } = guard();
3601
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3602
+ guard();
3603
+ const message = await client.message(accountId, messageId);
3604
+ guard();
3605
+ const retainedRecall = !!nativeRecallInfo(message);
3606
+ 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
+ if (message.serverId !== serverId) throw Error("The exact server identity does not match this receipt; the local message identity may have been reused");
3608
+ await allowed();
3609
+ guard();
3610
+ const key = nativeRecallKey(accountId, conversation, messageId, serverId);
3611
+ let operation;
3612
+ let operationId;
3613
+ const result = (status2, extra = {}) => {
3614
+ const details = {
3615
+ messageId,
3616
+ serverId,
3617
+ threadId: conversation,
3618
+ accountId,
3619
+ action: retainedRecall ? "inspect_recalled" : "recall",
3620
+ idempotencyKey: key,
3621
+ operationId,
3622
+ operationState: operation?.state,
3623
+ status: status2,
3624
+ retryable: false,
3625
+ ...extra
3626
+ };
3627
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
3628
+ };
3629
+ try {
3630
+ operation = await client.recallMessage(accountId, messageId, serverId, key, signal);
3631
+ const checkOperation = (candidate) => {
3632
+ if (!candidate || typeof candidate.id !== "string" || !candidate.id || operationId && candidate.id !== operationId || candidate.accountId !== accountId || candidate.kind !== "message_recall") throw Error("Recall operation identity is invalid");
3633
+ };
3634
+ checkOperation(operation);
3635
+ operationId = operation.id;
3636
+ const deadline = Date.now() + Math.max(0, Math.min(dependencies.waitMs ?? 2e3, 1e4));
3637
+ while (["queued", "running", "submitting"].includes(operation.state) && Date.now() < deadline) {
3638
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, deadline - Date.now())));
3639
+ guard();
3640
+ const remaining = Math.max(1, deadline - Date.now());
3641
+ const next = await client.operation(accountId, operation.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
3642
+ checkOperation(next);
3643
+ operation = next;
3644
+ }
3645
+ guard();
3646
+ } catch (error) {
3647
+ const reason = error instanceof NativeApiError ? error.code : "recall_result_unavailable";
3648
+ 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." });
3649
+ }
3650
+ const receipt = operation.result && typeof operation.result === "object" ? operation.result : {};
3651
+ if (receipt.messageId !== void 0 && receipt.messageId !== messageId || receipt.threadId !== void 0 && receipt.threadId !== conversation || receipt.serverId !== void 0 && receipt.serverId !== serverId) throw Error("Recall operation target identity is invalid");
3652
+ const exactReceipt = receipt.messageId === messageId && receipt.threadId === conversation && receipt.serverId === serverId;
3653
+ const status = operation.state === "succeeded" && exactReceipt && ["recalled", "already_recalled"].includes(String(receipt.status)) ? String(receipt.status) : ["queued", "running", "submitting"].includes(operation.state) ? "pending" : operation.state === "failed" && exactReceipt && ["not_self", "not_eligible", "unavailable", "not_found"].includes(String(receipt.status)) ? String(receipt.status) : "needs_review";
3654
+ const boundedState = (value) => typeof value === "string" && /^[a-z0-9_]{1,80}$/.test(value) ? value : void 0;
3655
+ return result(status, {
3656
+ remoteOutcome: boundedState(receipt.remoteOutcome),
3657
+ profileProjection: boundedState(receipt.profileProjection),
3658
+ note: status === "pending" || status === "needs_review" ? "Inspect this durable operation; do not automatically submit a replacement recall." : void 0
3659
+ });
3660
+ }
3661
+ }];
3662
+ }
3663
+ function registerNativeRecallTools(api) {
3664
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeRecallTools(context) }, { names: names2 });
3665
+ }
3666
+
2901
3667
  // index.ts
2902
3668
  var plugin = {
2903
3669
  id: "agent-wechat",
@@ -2909,6 +3675,8 @@ var plugin = {
2909
3675
  const selected = resolveWeChatAccount(api.config);
2910
3676
  api.registerChannel({ plugin: wechatPlugin });
2911
3677
  registerNativeHistoryTools(api);
3678
+ registerNativeShareTools(api);
3679
+ registerNativeRecallTools(api);
2912
3680
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
2913
3681
  };
2914
3682
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {