@agent-wechat/agent-wechat 1.0.0-beta.8 → 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) {
@@ -577,11 +598,22 @@ function requireNativeIdentity(value) {
577
598
  }
578
599
  }
579
600
  function nativeChildEnvironment(token, environment = process.env) {
580
- 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]]));
581
602
  }
582
603
  function nativeHostTarget(platform = process.platform, arch = process.arch) {
583
- 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");
584
- 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;
585
617
  }
586
618
  async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "", environment = nativeChildEnvironment("")) {
587
619
  const target = nativeHostTarget();
@@ -590,40 +622,48 @@ async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "",
590
622
  if (!candidate) {
591
623
  for (const directory of path.split(delimiter).filter(isAbsolute)) {
592
624
  try {
593
- await access(join(directory, "wx"), constants.X_OK);
594
- candidate = join(directory, "wx");
625
+ await access(join(directory, target.executable), constants.X_OK);
626
+ candidate = join(directory, target.executable);
595
627
  break;
596
628
  } catch {
597
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
+ }
598
639
  }
599
640
  }
600
641
  if (!candidate) throw new Error("native_binary_missing: install native wx separately or configure its absolute path");
601
642
  candidate = await realpath(candidate);
602
- if (candidate.endsWith("/scripts/native-launcher.mjs")) {
643
+ if (candidate.replaceAll("\\", "/").endsWith("/scripts/native-launcher.mjs")) {
603
644
  const root = dirname(dirname(candidate));
604
645
  const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
605
646
  if (pkg.name !== "@agent-wechat/cli" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)) throw new Error("Unsupported native CLI launcher");
606
647
  const manifest = JSON.parse(await readFile(join(root, "native-release.json"), "utf8"));
607
- 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"));
608
649
  if (manifest.version !== pkg.version || !asset || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("Native CLI manifest is incompatible");
609
- 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));
610
651
  const bytes = await readFile(candidate);
611
652
  if (bytes.length !== asset.bytes || createHash2("sha256").update(bytes).digest("hex") !== asset.sha256) throw new Error("Installed native executable checksum mismatch");
612
653
  }
613
654
  const stat = await lstat(candidate);
614
655
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Native executable must be a regular file");
615
656
  await access(candidate, constants.X_OK);
616
- const magic = (await readFile(candidate)).subarray(0, 4);
617
- 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");
618
658
  const { stdout } = await exec(candidate, ["--json", "version"], { env: environment, timeout: 5e3, maxBuffer: 65536 });
619
659
  const version = JSON.parse(stdout);
620
660
  requireNativeIdentity(version);
621
661
  if (version.target !== target.rustTarget) throw new Error("native_binary_incompatible: executable architecture differs from this host");
622
662
  return candidate;
623
663
  }
624
- async function portOccupied(url) {
664
+ async function portOccupied(url2) {
625
665
  return new Promise((resolve, reject) => {
626
- 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)) });
627
667
  socket.setTimeout(1e3);
628
668
  socket.once("connect", () => {
629
669
  socket.destroy();
@@ -640,7 +680,7 @@ async function portOccupied(url) {
640
680
  });
641
681
  });
642
682
  }
643
- async function stopOwnedChild(child) {
683
+ async function stopOwnedChild(child, graceful) {
644
684
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
645
685
  await new Promise((resolve) => {
646
686
  const kill = setTimeout(() => {
@@ -650,7 +690,10 @@ async function stopOwnedChild(child) {
650
690
  clearTimeout(kill);
651
691
  resolve();
652
692
  });
653
- 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");
654
697
  });
655
698
  }
656
699
  async function startNativeHost(config) {
@@ -665,11 +708,11 @@ async function startNativeHost(config) {
665
708
  });
666
709
  }
667
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");
668
- const url = validateNativeBaseUrl(`http://${config.listen ?? "127.0.0.1:6174"}`);
669
- 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");
670
713
  const executable = await resolveNativeExecutable(config.executable, void 0, nativeChildEnvironment(token));
671
- const client = new NativeWeChatClient({ baseUrl: url.href, token, timeoutMs: 1e3 });
672
- 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];
673
716
  if (config.serverConfig) args.push("--server-config", config.serverConfig);
674
717
  const child = spawn(executable, args, { env: nativeChildEnvironment(token), stdio: ["pipe", "ignore", "pipe"], shell: false });
675
718
  child.stderr?.on("data", () => {
@@ -692,10 +735,21 @@ async function startNativeHost(config) {
692
735
  if (exited) throw new Error("Native owned child exited before readiness");
693
736
  try {
694
737
  const status = await client.status();
695
- const dataDir = await realpath(config.dataDir);
738
+ const canonical = await realpath(config.dataDir);
739
+ const dataDir = process.platform === "win32" ? toNamespacedPath(canonical) : canonical;
696
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");
697
741
  requireNativeIdentity(status.version);
698
- 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
+ }));
699
753
  } catch (error) {
700
754
  if (String(error).includes("native_owner_conflict")) throw error;
701
755
  await new Promise((resolve) => setTimeout(resolve, 100));
@@ -1024,25 +1078,25 @@ function appendReference(body, reference) {
1024
1078
  return body ? `${body}
1025
1079
  ${reference}` : reference;
1026
1080
  }
1027
- function safeDisplayName(name) {
1028
- return name.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1081
+ function safeDisplayName(name2) {
1082
+ return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1029
1083
  }
1030
1084
  function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
1031
1085
  if (!attachment) return originalBody;
1032
- const name = safeDisplayName(attachment.filename);
1086
+ const name2 = safeDisplayName(attachment.filename);
1033
1087
  if (attachment.status !== "ready" || !attachment.path) {
1034
1088
  const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
1035
1089
  if (attachment.status === "pending") {
1036
- return appendReference(originalBody, `[${label} pending: ${name}]`);
1090
+ return appendReference(originalBody, `[${label} pending: ${name2}]`);
1037
1091
  }
1038
1092
  if (attachment.status === "expired") {
1039
- 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.]`);
1040
1094
  }
1041
- return appendReference(originalBody, `[${label} unavailable: ${name} (${attachment.status})]`);
1095
+ return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
1042
1096
  }
1043
1097
  if (attachment.kind === "file") {
1044
- const prefix = originalBody.trim() === name ? "" : originalBody;
1045
- return appendReference(prefix, `[File: ${name}]
1098
+ const prefix = originalBody.trim() === name2 ? "" : originalBody;
1099
+ return appendReference(prefix, `[File: ${name2}]
1046
1100
  [Local file: <${attachment.path}>]`);
1047
1101
  }
1048
1102
  if (exposeNonVisualPath) {
@@ -1061,11 +1115,117 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
1061
1115
  return body;
1062
1116
  }
1063
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
+
1064
1188
  // src/native-media.ts
1065
1189
  function kind(type) {
1066
1190
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
1067
1191
  }
1068
- 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
+ }
1069
1229
  async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1070
1230
  const deadline = Date.now() + timeoutMs;
1071
1231
  let job = initial;
@@ -1103,6 +1263,43 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
1103
1263
  }
1104
1264
  throw Error("Native model image representation exceeds its bound");
1105
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
+ }
1106
1303
  async function prepareNativeQuote(params) {
1107
1304
  const { client, account, message } = params;
1108
1305
  const guard = () => {
@@ -1112,7 +1309,6 @@ async function prepareNativeQuote(params) {
1112
1309
  guard();
1113
1310
  const comment = (message.body ?? "").slice(0, 16384);
1114
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 } });
1115
- if (!message.quote) return unavailable("invalid");
1116
1312
  await params.beforeMediaAccess?.();
1117
1313
  guard();
1118
1314
  let resolution;
@@ -1121,10 +1317,13 @@ async function prepareNativeQuote(params) {
1121
1317
  } catch (error) {
1122
1318
  guard();
1123
1319
  if (!(error instanceof NativeApiError)) throw error;
1124
- 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");
1125
1322
  }
1126
1323
  guard();
1127
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");
1128
1327
  if (resolution.state === "embedded") {
1129
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");
1130
1329
  const author = { senderId: resolution.senderId ?? null, direction: null };
@@ -1182,6 +1381,25 @@ async function prepareNativeQuote(params) {
1182
1381
  };
1183
1382
  }
1184
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) {
1185
1403
  const { client, account, message, core } = params;
1186
1404
  const attachmentKind = kind(message.type);
1187
1405
  const guard = () => {
@@ -1189,6 +1407,7 @@ async function prepareNativeAttachment(params) {
1189
1407
  params.assertCurrent?.();
1190
1408
  };
1191
1409
  guard();
1410
+ if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1192
1411
  const authorize = async () => {
1193
1412
  guard();
1194
1413
  await params.beforeMediaAccess?.();
@@ -1196,6 +1415,7 @@ async function prepareNativeAttachment(params) {
1196
1415
  };
1197
1416
  if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
1198
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);
1199
1419
  if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
1200
1420
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1201
1421
  const accountId = account.native.accountId;
@@ -1223,34 +1443,38 @@ async function prepareNativeAttachment(params) {
1223
1443
  }
1224
1444
  let job;
1225
1445
  let transferError;
1226
- if (!asset && initialCandidate?.jobId) {
1446
+ if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1227
1447
  await authorize();
1228
1448
  job = await client.job(accountId, initialCandidate.jobId, params.signal);
1229
1449
  guard();
1230
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");
1231
1451
  assets = await scopedAssets();
1232
1452
  asset = select(assets, message.type, quality);
1453
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1233
1454
  if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1234
1455
  }
1235
- 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();
1236
1458
  try {
1237
- await authorize();
1238
1459
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1239
1460
  guard();
1240
1461
  job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1241
1462
  guard();
1242
1463
  } catch (error) {
1243
1464
  guard();
1244
- 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 };
1245
1467
  transferError = error;
1246
1468
  }
1247
1469
  assets = await scopedAssets();
1248
1470
  asset = select(assets, message.type, quality);
1471
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1249
1472
  if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1250
1473
  }
1251
1474
  const candidate = transferCandidate();
1252
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, "_");
1253
1476
  if (!asset) {
1477
+ if (candidate?.state === "recalled" || transferError?.code === "message_recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1254
1478
  const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
1255
1479
  const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
1256
1480
  return {
@@ -1346,7 +1570,7 @@ async function prepareNativeAttachment(params) {
1346
1570
  pending: false
1347
1571
  };
1348
1572
  }
1349
- async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
1573
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
1350
1574
  const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
1351
1575
  let bytes;
1352
1576
  let filename;
@@ -1361,7 +1585,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1361
1585
  }
1362
1586
  const reader = response.body?.getReader();
1363
1587
  if (!reader) throw new Error("Native outbound source has no bytes");
1364
- const chunks = [];
1588
+ const chunks2 = [];
1365
1589
  let size = 0;
1366
1590
  try {
1367
1591
  while (true) {
@@ -1369,7 +1593,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1369
1593
  if (done) break;
1370
1594
  size += value.length;
1371
1595
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1372
- chunks.push(value);
1596
+ chunks2.push(value);
1373
1597
  }
1374
1598
  } catch (error) {
1375
1599
  await reader.cancel();
@@ -1379,7 +1603,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1379
1603
  }
1380
1604
  bytes = new Uint8Array(size);
1381
1605
  let offset = 0;
1382
- for (const chunk of chunks) {
1606
+ for (const chunk of chunks2) {
1383
1607
  bytes.set(chunk, offset);
1384
1608
  offset += chunk.length;
1385
1609
  }
@@ -1391,7 +1615,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1391
1615
  try {
1392
1616
  const stat = await file.stat();
1393
1617
  if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1394
- const chunks = [];
1618
+ const chunks2 = [];
1395
1619
  let size = 0;
1396
1620
  while (true) {
1397
1621
  const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
@@ -1399,9 +1623,9 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1399
1623
  if (!bytesRead) break;
1400
1624
  size += bytesRead;
1401
1625
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1402
- chunks.push(chunk.subarray(0, bytesRead));
1626
+ chunks2.push(chunk.subarray(0, bytesRead));
1403
1627
  }
1404
- bytes = Buffer.concat(chunks, size);
1628
+ bytes = Buffer.concat(chunks2, size);
1405
1629
  } finally {
1406
1630
  await file.close();
1407
1631
  }
@@ -1410,43 +1634,39 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
1410
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";
1411
1635
  }
1412
1636
  if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1637
+ await beforeSend?.();
1413
1638
  const accountId = account.native.accountId;
1414
1639
  const source = await client.upload(accountId, bytes, filename, mime);
1415
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 };
1416
- const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1417
- 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`);
1418
- return `agent-wechat-native:${accountId}:${job.id}`;
1419
- }
1420
-
1421
- // src/native-send.ts
1422
- async function submitNativeText(client, accountId, threadId2, text, key, options = {}) {
1423
- let intent = await client.sendText(accountId, threadId2, text, key);
1424
- const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1425
- let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1426
- while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1427
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1428
- const remaining = deadline - Date.now();
1429
- if (remaining <= 0) break;
1430
- intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1431
- delay = Math.min(delay * 2, 1e3);
1432
- }
1433
- if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1434
- 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 });
1435
1649
  }
1436
1650
 
1437
1651
  // src/outbound-media.ts
1438
- async function sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice = false, idempotencyKey) {
1652
+ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
1439
1653
  const account = resolveWeChatAccount(cfg);
1440
1654
  if (!account?.native) throw new Error("No native daemon configured");
1441
1655
  const { client } = await ensureNativeHost(account.native);
1442
1656
  const key = idempotencyKey ?? randomUUID2();
1443
1657
  if (!mediaUrl) {
1444
1658
  if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
1445
- const send = await submitNativeText(client, account.native.accountId, to, text, key);
1446
- 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
+ );
1447
1667
  }
1448
1668
  const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
1449
- 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"));
1450
1670
  return result;
1451
1671
  }
1452
1672
 
@@ -1474,9 +1694,16 @@ var NativeConsumerState = class {
1474
1694
  for (const [id2, item] of Object.entries(parsed.items)) {
1475
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");
1476
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();
1477
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");
1478
1700
  if (item.state === "dispatching") item.state = "needs_review";
1479
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
+ }
1480
1707
  this.value = parsed;
1481
1708
  this.persist();
1482
1709
  }
@@ -1484,12 +1711,22 @@ var NativeConsumerState = class {
1484
1711
  get cursor() {
1485
1712
  return this.value.cursor;
1486
1713
  }
1714
+ get accountId() {
1715
+ return this.value.accountId;
1716
+ }
1487
1717
  items() {
1488
1718
  return Object.values(this.value.items);
1489
1719
  }
1490
1720
  get(messageId) {
1491
1721
  return this.value.items[messageId];
1492
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
+ }
1493
1730
  persist() {
1494
1731
  const directory = dirname2(this.file);
1495
1732
  mkdirSync(directory, { recursive: true, mode: 448 });
@@ -1533,42 +1770,129 @@ var NativeConsumerState = class {
1533
1770
  accept(cursor, message, origin = "unknown") {
1534
1771
  if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw new Error("Native journal cursor cannot move backward");
1535
1772
  if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
1773
+ let replaced = false;
1536
1774
  this.mutate(() => {
1537
- 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)) {
1538
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));
1539
1784
  for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
1540
- if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1541
- 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 };
1542
1801
  }
1543
1802
  this.value.cursor = cursor;
1544
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;
1816
+ }
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];
1545
1865
  }
1546
- update(messageId, update) {
1866
+ update(messageId, update, revision) {
1547
1867
  const item = this.value.items[messageId];
1868
+ if (revision !== void 0 && item?.revision !== revision) return false;
1548
1869
  if (!item) throw new Error("Unknown native custody message");
1549
1870
  this.mutate(() => Object.assign(this.value.items[messageId], update));
1871
+ return true;
1550
1872
  }
1551
1873
  groupHistory(threadId2, beforeId, limit) {
1552
1874
  const before = this.value.items[beforeId]?.cursor;
1553
1875
  if (!before || limit <= 0) return [];
1554
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);
1555
1877
  }
1556
- bufferGroupMessage(messageId, limit) {
1878
+ bufferGroupMessage(messageId, limit, revision) {
1557
1879
  const item = this.value.items[messageId];
1880
+ if (revision !== void 0 && item?.revision !== revision) return false;
1558
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");
1559
1882
  this.mutate(() => {
1560
1883
  Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
1561
1884
  const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
1562
- const thread = history.filter((value) => value.message.threadId === item.message.threadId);
1563
- 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;
1564
1887
  const retained = history.filter((value) => value.groupContext);
1565
1888
  for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
1566
1889
  });
1890
+ return true;
1567
1891
  }
1568
1892
  beginDispatch(messageId, dispatchManifest, historyIds = []) {
1569
- 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");
1570
1894
  this.mutate(() => {
1571
- Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
1895
+ Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest, invalidatedByRecall: false });
1572
1896
  for (const id2 of historyIds) this.value.items[id2].groupContext = false;
1573
1897
  });
1574
1898
  }
@@ -1581,6 +1905,7 @@ var NativeConsumerState = class {
1581
1905
  if (!item || item.state !== "dispatching") throw new Error("Prepared input manifest is not in dispatch custody");
1582
1906
  return item;
1583
1907
  });
1908
+ const revisions = items.map((item) => item.revision);
1584
1909
  if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
1585
1910
  const manifestHash = createHash6("sha256").update(JSON.stringify({
1586
1911
  accountId: this.value.accountId,
@@ -1591,8 +1916,15 @@ var NativeConsumerState = class {
1591
1916
  const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
1592
1917
  if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
1593
1918
  this.mutate(() => {
1594
- 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()) {
1595
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");
1596
1928
  if (item.runId && item.runId !== runId) throw new Error("Native prepared manifest was assigned to another run; operator review required");
1597
1929
  Object.assign(item, { state: "handed_off", runId, manifestHash, readPending: readPolicy === "on_model_dispatch", handoffUnconfirmed: false });
1598
1930
  }
@@ -1611,6 +1943,9 @@ var digest = (value) => createHash7("sha256").update(value).digest("hex");
1611
1943
  function prune() {
1612
1944
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1613
1945
  }
1946
+ function nativeSourceMessageSid(message) {
1947
+ return `wechat-native:${message.accountId}:${message.id}:${digest(JSON.stringify([message.threadId, message.serverId ?? null]))}`;
1948
+ }
1614
1949
  function nativeDispatchManifest(params) {
1615
1950
  const history = params.history ?? [];
1616
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");
@@ -1618,7 +1953,7 @@ function nativeDispatchManifest(params) {
1618
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");
1619
1954
  return Object.freeze({ ...asset });
1620
1955
  }));
1621
- const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
1956
+ const messageSid = nativeSourceMessageSid(params.message);
1622
1957
  const manifest = {
1623
1958
  schemaVersion: 2,
1624
1959
  runId: randomUUID4(),
@@ -1643,14 +1978,15 @@ function nativeDispatchManifest(params) {
1643
1978
  params.message.mentions,
1644
1979
  params.message.serverId,
1645
1980
  params.message.appMessageType,
1646
- params.message.quote
1981
+ params.message.quote,
1982
+ params.message.link
1647
1983
  ])),
1648
1984
  role: "trigger"
1649
1985
  })]),
1650
1986
  ...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
1651
1987
  id: message.id,
1652
1988
  version: message.version ?? "",
1653
- 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])),
1654
1990
  role: "history"
1655
1991
  }))) } : {},
1656
1992
  assets,
@@ -1666,30 +2002,40 @@ function trackNativePreparedInput(manifest, record) {
1666
2002
  if (!enabled || pending.size >= 1024) return;
1667
2003
  pending.set(manifest.runId, { manifest, record, expires: Date.now() + 15 * 60 * 1e3 });
1668
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
+ }
1669
2015
  function promptMatches(prompt, manifest) {
1670
2016
  if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 1024 * 1024) return false;
1671
- const text = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
1672
- const candidates = [text];
2017
+ const text3 = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
2018
+ const candidates = [text3];
1673
2019
  const sender = `Conversation info: \u27E6openclaw:ctx\u27E7
1674
2020
  \`\`\`json
1675
2021
  ${JSON.stringify({ sender: { id: manifest.senderId } })}
1676
2022
  \`\`\`
1677
2023
 
1678
2024
  `;
1679
- if (manifest.threadId.endsWith("@chatroom") && text.startsWith(sender)) candidates.push(text.slice(sender.length));
1680
- 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));
1681
2027
  }
1682
- function normalizedPromptMatches(text, manifest) {
2028
+ function normalizedPromptMatches(text3, manifest) {
1683
2029
  const runtimeAssets = manifest.assets.filter((asset) => asset.runtimeMedia);
1684
2030
  if (runtimeAssets.length) {
1685
2031
  if (runtimeAssets.length !== 1) return false;
1686
2032
  const asset = runtimeAssets[0];
1687
- 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);
1688
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;
1689
- text = text.slice(header[0].length);
1690
- 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\]$/, "");
1691
2037
  }
1692
- return digest(text) === manifest.inputBodySha256;
2038
+ return digest(text3) === manifest.inputBodySha256;
1693
2039
  }
1694
2040
  function currentMessageFacts(messages) {
1695
2041
  if (!Array.isArray(messages) || messages.length > 1e3) return [];
@@ -1750,8 +2096,8 @@ function observeNativeAgentEnd(event, ctx) {
1750
2096
  const blocks = typeof user.content === "string" ? [{ type: "text", text: user.content }] : user.content;
1751
2097
  if (!Array.isArray(blocks) || blocks.length > 32) return;
1752
2098
  if (blocks.some((block) => !["text", "image"].includes(block?.type) || block.type === "text" && typeof block.text !== "string")) return;
1753
- const text = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
1754
- 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;
1755
2101
  const images = blocks.filter((block) => block?.type === "image");
1756
2102
  const expectedImages = m.assets.filter((asset) => asset.role === "model_image");
1757
2103
  if (images.length !== expectedImages.length) return;
@@ -1761,7 +2107,7 @@ function observeNativeAgentEnd(event, ctx) {
1761
2107
  const bytes = Buffer.from(image.data, "base64");
1762
2108
  if (bytes.length !== asset.byteLength || digest(bytes) !== asset.sha256) return;
1763
2109
  }
1764
- 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;
1765
2111
  item.record(m.runId);
1766
2112
  pending.delete(m.runId);
1767
2113
  }
@@ -1876,22 +2222,22 @@ async function projectNativeRead(client, accountId, state) {
1876
2222
  for (const threadId2 of threads) {
1877
2223
  for (let attempt = 0; attempt < 2; attempt++) {
1878
2224
  let cursor;
1879
- let thread;
2225
+ let thread3;
1880
2226
  const seen = /* @__PURE__ */ new Set();
1881
2227
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
1882
2228
  const page = await client.threads(accountId, cursor);
1883
- thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
1884
- if (thread || !page.nextCursor) break;
2229
+ thread3 = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
2230
+ if (thread3 || !page.nextCursor) break;
1885
2231
  if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
1886
2232
  seen.add(page.nextCursor);
1887
2233
  cursor = page.nextCursor;
1888
2234
  }
1889
- if (!thread) break;
2235
+ if (!thread3) break;
1890
2236
  try {
1891
- const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
2237
+ const receipt = await client.markRead(accountId, thread3.id, thread3.readStateVersion);
1892
2238
  const notification = receipt?.phoneNotification?.state;
1893
2239
  const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
1894
- 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);
1895
2241
  break;
1896
2242
  } catch (error) {
1897
2243
  if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
@@ -1900,13 +2246,72 @@ async function projectNativeRead(client, accountId, state) {
1900
2246
  }
1901
2247
  }
1902
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
+ }
1903
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) {
1904
2308
  const { client, state, account, message, cfg, core } = params;
1905
2309
  const readPolicy = account.native?.readPolicy ?? "on_inbound";
1906
2310
  const custody = state.get(message.id);
1907
- 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";
1908
2313
  if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
1909
- state.update(message.id, { state: "ignored" });
2314
+ state.update(message.id, { state: "ignored" }, revision);
1910
2315
  return "ignored";
1911
2316
  }
1912
2317
  const isGroup = message.threadId.endsWith("@chatroom");
@@ -1941,32 +2346,50 @@ async function dispatchNativeMessage(params) {
1941
2346
  const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
1942
2347
  if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
1943
2348
  if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
1944
- state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
2349
+ state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg), revision);
1945
2350
  return "ignored";
1946
2351
  }
1947
- state.update(message.id, { state: "ignored" });
2352
+ state.update(message.id, { state: "ignored" }, revision);
1948
2353
  return "ignored";
1949
2354
  }
1950
2355
  let preparedMedia;
1951
2356
  const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
1952
- try {
1953
- preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg, allowQuotedMessage });
1954
- } catch {
1955
- state.update(message.id, { state: "needs_review" });
1956
- return "needs_review";
1957
- }
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 });
1958
2368
  if (preparedMedia.pending) return "pending";
1959
2369
  preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
2370
+ await recheck([message]);
1960
2371
  const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
1961
2372
  const admitted = [];
1962
2373
  for (const item of buffered) {
1963
2374
  if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
1964
2375
  }
1965
2376
  const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
1966
- 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
+ }),
1967
2389
  core,
1968
2390
  cfg
1969
2391
  ));
2392
+ await recheck([message, ...admitted]);
1970
2393
  const context = buildNativeContext(history, message, preparedMedia);
1971
2394
  const inputBody = context.inputBody;
1972
2395
  const route = core.channel.routing.resolveAgentRoute({
@@ -1996,7 +2419,7 @@ async function dispatchNativeMessage(params) {
1996
2419
  SenderId: message.senderId,
1997
2420
  Provider: "agent-wechat",
1998
2421
  Surface: "agent-wechat",
1999
- MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
2422
+ MessageSid: nativeSourceMessageSid(message),
2000
2423
  WasMentioned: isGroup ? wasMentioned : void 0,
2001
2424
  Attachments: context.attachments,
2002
2425
  InboundHistory: context.history,
@@ -2006,6 +2429,7 @@ async function dispatchNativeMessage(params) {
2006
2429
  ...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
2007
2430
  });
2008
2431
  await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
2432
+ await recheck([message, ...admitted]);
2009
2433
  const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
2010
2434
  const { onModelSelected, ...prefixOptions } = pipeline;
2011
2435
  const dispatchManifest = nativeDispatchManifest({
@@ -2018,9 +2442,11 @@ async function dispatchNativeMessage(params) {
2018
2442
  assets: context.assets,
2019
2443
  history: admitted
2020
2444
  });
2445
+ assertCurrent();
2021
2446
  state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
2022
- 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;
2023
2448
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2449
+ const beforeSend = () => recheck([message, ...admitted]);
2024
2450
  let failed = false;
2025
2451
  let delivery = 0;
2026
2452
  try {
@@ -2030,15 +2456,19 @@ async function dispatchNativeMessage(params) {
2030
2456
  dispatcherOptions: {
2031
2457
  ...prefixOptions,
2032
2458
  deliver: async (payload) => {
2459
+ params.signal?.throwIfAborted();
2460
+ if (state.get(message.id)?.invalidatedByRecall) return;
2461
+ await beforeSend();
2033
2462
  const urls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
2034
2463
  if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
2035
2464
  if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
2036
- for (const url of urls) {
2037
- const key = createHash8("sha256").update(`${account.native.accountId}\0${message.id}\0${delivery++}`).digest("hex");
2038
- 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);
2039
2468
  }
2040
2469
  if (payload.text) {
2041
- 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");
2042
2472
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
2043
2473
  }
2044
2474
  if (urls.length || payload.text) params.onDelivered?.();
@@ -2053,18 +2483,34 @@ async function dispatchNativeMessage(params) {
2053
2483
  // and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
2054
2484
  replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
2055
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";
2056
2489
  const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
2057
2490
  state.update(message.id, {
2058
2491
  state: failed ? "needs_review" : "completed",
2059
2492
  readPending,
2060
2493
  handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
2061
- });
2494
+ }, revision);
2062
2495
  return failed ? "needs_review" : "completed";
2063
2496
  } catch {
2064
- state.update(message.id, { state: "needs_review" });
2497
+ if (state.isRecalled(message)) return "ignored";
2498
+ state.update(message.id, { state: "needs_review" }, revision);
2065
2499
  return "needs_review";
2066
2500
  }
2067
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
+ }
2068
2514
  function startNativeReadPump(task, failed, intervalMs = 5e3) {
2069
2515
  let active2 = false, stopped = false;
2070
2516
  const timer = setInterval(() => {
@@ -2110,6 +2556,7 @@ async function startNativeMonitor(opts) {
2110
2556
  message: item.message,
2111
2557
  cfg,
2112
2558
  core,
2559
+ signal: opts.abortSignal,
2113
2560
  onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
2114
2561
  });
2115
2562
  }
@@ -2130,9 +2577,24 @@ async function startNativeMonitor(opts) {
2130
2577
  handoffUnconfirmed
2131
2578
  });
2132
2579
  };
2133
- 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
+ };
2134
2595
  const stopReadPump = startNativeReadPump(async () => {
2135
2596
  await flushRead();
2597
+ scheduleProcess();
2136
2598
  if (!opts.abortSignal.aborted) opts.setStatus({
2137
2599
  accountId: opts.account.accountId,
2138
2600
  handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
@@ -2142,18 +2604,15 @@ async function startNativeMonitor(opts) {
2142
2604
  while (!opts.abortSignal.aborted) {
2143
2605
  try {
2144
2606
  if (state.cursor !== "0") await host2.client.ack(native.accountId, "openclaw", state.cursor);
2145
- await process2();
2607
+ intakeReady = false;
2608
+ await synchronizeNativeEvents(host2.client, native.accountId, state);
2609
+ intakeReady = true;
2610
+ scheduleProcess();
2146
2611
  for await (const event of host2.client.watch(native.accountId, state.cursor, opts.abortSignal)) {
2147
- let message;
2148
- if (event.kind === "message.committed") {
2149
- if (typeof event.data.messageId !== "string") throw new Error("Native committed message identity is missing; cursor retained");
2150
- message = await host2.client.message(native.accountId, event.data.messageId);
2151
- if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
2152
- }
2153
- state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
2154
- 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() });
2155
2614
  await host2.client.ack(native.accountId, "openclaw", state.cursor);
2156
- await process2();
2615
+ scheduleProcess();
2157
2616
  backoff = 250;
2158
2617
  }
2159
2618
  if (!opts.abortSignal.aborted) throw new Error("Native event stream closed");
@@ -2223,11 +2682,17 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
2223
2682
  }
2224
2683
  return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
2225
2684
  }
2226
- async function sendNativeText(account, threadId2, text, key = randomUUID5()) {
2685
+ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
2227
2686
  const native = account.native;
2228
2687
  const { client } = await ensureNativeHost(native);
2229
- const send = await submitNativeText(client, native.accountId, threadId2, text, key);
2230
- 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
+ );
2231
2696
  }
2232
2697
  function createNativeLoginTool(account) {
2233
2698
  return {
@@ -2254,10 +2719,10 @@ function createNativeLoginTool(account) {
2254
2719
  }
2255
2720
 
2256
2721
  // src/channel.ts
2257
- async function sendWeChatText(cfg, to, text) {
2722
+ async function sendWeChatText(cfg, to, text3) {
2258
2723
  const account = resolveWeChatAccount(cfg);
2259
2724
  if (!account?.serverUrl) throw new Error("No native daemon configured");
2260
- return sendNativeText(account, to, text);
2725
+ return sendNativeText(account, to, text3);
2261
2726
  }
2262
2727
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2263
2728
  id: "agent-wechat",
@@ -2267,13 +2732,13 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
2267
2732
  messageSendingHooks: true
2268
2733
  },
2269
2734
  outbound: {
2270
- sendText: async ({ cfg, to, text }) => ({
2735
+ sendText: async ({ cfg, to, text: text3 }) => ({
2271
2736
  channel: "agent-wechat",
2272
- messageId: await sendWeChatText(cfg, to, text)
2737
+ ...await sendWeChatText(cfg, to, text3)
2273
2738
  }),
2274
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2739
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2275
2740
  channel: "agent-wechat",
2276
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2741
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2277
2742
  })
2278
2743
  }
2279
2744
  });
@@ -2396,7 +2861,7 @@ var wechatPlugin = {
2396
2861
  },
2397
2862
  // ---- Mention adapter ----
2398
2863
  mentions: {
2399
- stripMentions: ({ text, ctx }) => normalizeWeChatCommandBody(text, {
2864
+ stripMentions: ({ text: text3, ctx }) => normalizeWeChatCommandBody(text3, {
2400
2865
  isGroup: ctx.ChatType === "group",
2401
2866
  wasMentioned: ctx.WasMentioned === true
2402
2867
  })
@@ -2415,13 +2880,13 @@ var wechatPlugin = {
2415
2880
  // ---- Outbound adapter for OpenClaw hosts using the outbound hook ----
2416
2881
  outbound: {
2417
2882
  deliveryMode: "direct",
2418
- sendText: async ({ cfg, to, text }) => ({
2883
+ sendText: async ({ cfg, to, text: text3 }) => ({
2419
2884
  channel: "agent-wechat",
2420
- messageId: await sendWeChatText(cfg, to, text)
2885
+ ...await sendWeChatText(cfg, to, text3)
2421
2886
  }),
2422
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2887
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2423
2888
  channel: "agent-wechat",
2424
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2889
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2425
2890
  })
2426
2891
  },
2427
2892
  message: wechatMessageAdapter,
@@ -2602,9 +3067,9 @@ function createNativeFacade(config, dependencies = {}) {
2602
3067
  try {
2603
3068
  const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
2604
3069
  const headers = new Headers();
2605
- for (const name of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
2606
- const value = req.headers[name];
2607
- 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);
2608
3073
  }
2609
3074
  const length = req.headers["content-length"];
2610
3075
  if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
@@ -2628,9 +3093,9 @@ function createNativeFacade(config, dependencies = {}) {
2628
3093
  response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
2629
3094
  }
2630
3095
  res.statusCode = response.status;
2631
- for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
2632
- const value = response.headers.get(name);
2633
- 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);
2634
3099
  }
2635
3100
  res.setHeader("x-content-type-options", "nosniff");
2636
3101
  const reader = response.body?.getReader();
@@ -2689,18 +3154,29 @@ function object(value) {
2689
3154
  if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
2690
3155
  return value;
2691
3156
  }
2692
- function string(value, name, max = 4096) {
2693
- 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}`);
2694
3159
  return value.trim();
2695
3160
  }
2696
3161
  function threadId(value) {
2697
3162
  return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
2698
3163
  }
2699
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
+ };
2700
3175
  const isQuote = message.type === 49 && message.appMessageType === 57;
2701
3176
  const isForward = message.type === 49 && message.appMessageType === 19;
2702
- 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";
2703
- 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 ?? ""}
2704
3180
  [Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
2705
3181
  return {
2706
3182
  id: message.id,
@@ -2711,6 +3187,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
2711
3187
  type: message.type,
2712
3188
  appMessageType: message.appMessageType,
2713
3189
  quote: message.quote,
3190
+ link: isLink ? message.link : void 0,
2714
3191
  createdAt: message.createdAt,
2715
3192
  body: body.slice(0, 4e3),
2716
3193
  bodyTruncated: body.length > 4e3,
@@ -2728,6 +3205,7 @@ function createLookupTools(context, dependencies, archive) {
2728
3205
  if (!currentConfig()) return [];
2729
3206
  const selected = resolveWeChatAccount(currentConfig());
2730
3207
  if (!selected?.native || !selected.enabled) return [];
3208
+ const configurationKey = nativeConfigurationKey(selected.native);
2731
3209
  if (!archive && context.messageChannel !== "agent-wechat") return [];
2732
3210
  if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
2733
3211
  if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
@@ -2749,9 +3227,9 @@ function createLookupTools(context, dependencies, archive) {
2749
3227
  if (!currentThread) return [];
2750
3228
  }
2751
3229
  const selectedId = selected.native.accountId;
2752
- const tool = (name, action, description, properties, required = []) => ({
2753
- name,
2754
- label: name.replaceAll("_", " "),
3230
+ const tool = (name2, action, description, properties, required = []) => ({
3231
+ name: name2,
3232
+ label: name2.replaceAll("_", " "),
2755
3233
  description,
2756
3234
  // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
2757
3235
  // public direct-only surface preserves actual image content for vision.
@@ -2764,7 +3242,7 @@ function createLookupTools(context, dependencies, archive) {
2764
3242
  context.assertInvocationCurrent?.();
2765
3243
  const cfg2 = currentConfig();
2766
3244
  const account2 = resolveWeChatAccount(cfg2);
2767
- 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");
2768
3246
  return { cfg: cfg2, account: account2, native: account2.native };
2769
3247
  };
2770
3248
  const { cfg, account, native } = guard();
@@ -2806,10 +3284,10 @@ function createLookupTools(context, dependencies, archive) {
2806
3284
  if (action === "threads") {
2807
3285
  const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
2808
3286
  const items = [];
2809
- for (const thread of page.items) {
2810
- if (isNativeTombstone(thread)) continue;
2811
- if (thread.accountId !== accountId) throw Error("Thread response crosses an account boundary");
2812
- 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 });
2813
3291
  }
2814
3292
  result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
2815
3293
  } else if (action === "message" || action === "media") {
@@ -2819,6 +3297,18 @@ function createLookupTools(context, dependencies, archive) {
2819
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");
2820
3298
  result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
2821
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
+ };
2822
3312
  let prepared;
2823
3313
  try {
2824
3314
  prepared = await prepareNativeAttachment({
@@ -2835,6 +3325,7 @@ function createLookupTools(context, dependencies, archive) {
2835
3325
  allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
2836
3326
  beforeMediaAccess: async () => {
2837
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();
2838
3329
  }
2839
3330
  });
2840
3331
  } catch (error) {
@@ -2843,7 +3334,7 @@ function createLookupTools(context, dependencies, archive) {
2843
3334
  const details2 = {
2844
3335
  messageId: message.id,
2845
3336
  threadId: message.threadId,
2846
- 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",
2847
3338
  retryable: error.retryable,
2848
3339
  reason: error.code
2849
3340
  };
@@ -2854,9 +3345,10 @@ function createLookupTools(context, dependencies, archive) {
2854
3345
  const details = {
2855
3346
  messageId: message.id,
2856
3347
  threadId: message.threadId,
2857
- 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",
2858
3349
  attachment: prepared.attachment,
2859
3350
  quote: prepared.quote,
3351
+ link: ["message_recalled", "media_identity_unavailable", "media_source_changed"].includes(prepared.reason ?? "") ? void 0 : message.link,
2860
3352
  variant: prepared.variant,
2861
3353
  jobId: prepared.jobId,
2862
3354
  retryable: prepared.retryable ?? false,
@@ -2866,6 +3358,13 @@ function createLookupTools(context, dependencies, archive) {
2866
3358
  const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
2867
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 };
2868
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
+ }
2869
3368
  return output;
2870
3369
  }
2871
3370
  } else {
@@ -2914,6 +3413,257 @@ function registerNativeHistoryTools(api) {
2914
3413
  api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
2915
3414
  }
2916
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
+
2917
3667
  // index.ts
2918
3668
  var plugin = {
2919
3669
  id: "agent-wechat",
@@ -2925,6 +3675,8 @@ var plugin = {
2925
3675
  const selected = resolveWeChatAccount(api.config);
2926
3676
  api.registerChannel({ plugin: wechatPlugin });
2927
3677
  registerNativeHistoryTools(api);
3678
+ registerNativeShareTools(api);
3679
+ registerNativeRecallTools(api);
2928
3680
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
2929
3681
  };
2930
3682
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {