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

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
@@ -22,7 +22,7 @@ var nativeConfigSchema = {
22
22
  }
23
23
  }] },
24
24
  consumerStateDir: { type: "string" },
25
- readPolicy: { type: "string", enum: ["never", "on_model_dispatch"] },
25
+ readPolicy: { type: "string", enum: ["never", "on_inbound", "on_model_dispatch"] },
26
26
  gatewayFacade: { type: "object", additionalProperties: false, required: ["enabled"], properties: { enabled: { type: "boolean" }, prefix: { type: "string" } } }
27
27
  }
28
28
  };
@@ -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))
@@ -169,6 +169,7 @@ var NativeWeChatClient = class {
169
169
  token;
170
170
  fetchImpl;
171
171
  timeoutMs;
172
+ streamIdleTimeoutMs;
172
173
  constructor(options) {
173
174
  this.baseUrl = validateNativeBaseUrl(options.baseUrl);
174
175
  const nodeEnvironment = globalThis.process?.env;
@@ -179,6 +180,9 @@ var NativeWeChatClient = class {
179
180
  this.token = options.token;
180
181
  this.fetchImpl = options.fetch ?? fetch;
181
182
  this.timeoutMs = options.timeoutMs ?? 3e4;
183
+ this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 45e3;
184
+ if (!Number.isInteger(this.streamIdleTimeoutMs) || this.streamIdleTimeoutMs < 1 || this.streamIdleTimeoutMs > 3e5)
185
+ throw new Error("Invalid native stream idle timeout");
182
186
  }
183
187
  async response(path, init = {}, stream = false) {
184
188
  const headers = new Headers(init.headers);
@@ -279,8 +283,24 @@ var NativeWeChatClient = class {
279
283
  throw new Error("Invalid login idempotency key");
280
284
  return this.request(`v1/accounts/${id(accountId)}/auth/login`, { method: "POST", headers: key ? { "Idempotency-Key": key } : void 0, body: JSON.stringify(options) });
281
285
  }
282
- operation(accountId, operationId) {
283
- 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;
284
304
  }
285
305
  cancelOperation(accountId, operationId) {
286
306
  return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { method: "DELETE" });
@@ -318,8 +338,8 @@ var NativeWeChatClient = class {
318
338
  groups(accountId, options) {
319
339
  return this.request(`v1/accounts/${id(accountId)}/groups${listQuery(options)}`);
320
340
  }
321
- thread(accountId, threadId) {
322
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}`);
341
+ thread(accountId, threadId2) {
342
+ return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}`);
323
343
  }
324
344
  contact(accountId, username) {
325
345
  return this.request(`v1/accounts/${id(accountId)}/contacts/${id(username)}`);
@@ -357,12 +377,12 @@ var NativeWeChatClient = class {
357
377
  syncGroups(accountId) {
358
378
  return this.request(`v1/accounts/${id(accountId)}/group-sync`, { method: "POST", body: "{}" });
359
379
  }
360
- async messages(accountId, threadId, options) {
361
- const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/messages${listQuery(options)}`);
380
+ async messages(accountId, threadId2, options) {
381
+ const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/messages${listQuery(options)}`);
362
382
  for (const msg of result.items) {
363
383
  if (isNativeTombstone(msg))
364
384
  continue;
365
- if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
385
+ if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId2 || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
366
386
  throw new Error("Invalid native message identity");
367
387
  }
368
388
  return result;
@@ -373,14 +393,37 @@ var NativeWeChatClient = class {
373
393
  throw new Error("Invalid native message identity");
374
394
  return message;
375
395
  }
376
- sendText(accountId, threadId, text, idempotencyKey) {
396
+ /** Resolve only the stored original in this message's own conversation. */
397
+ async quotedMessage(accountId, messageId) {
398
+ const quote = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/quote`);
399
+ if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
400
+ throw new Error("Invalid native quote identity");
401
+ return quote;
402
+ }
403
+ /** Account-wide local search, with the daemon's query-bound opaque cursor. */
404
+ async searchMessages(accountId, options) {
405
+ const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
406
+ for (const message of result.items) {
407
+ if (isNativeTombstone(message))
408
+ continue;
409
+ if (typeof message.id !== "string" || message.accountId !== accountId || typeof message.threadId !== "string" || !message.threadId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
410
+ throw new Error("Invalid native archive message identity");
411
+ }
412
+ return result;
413
+ }
414
+ sendText(accountId, threadId2, text3, idempotencyKey) {
377
415
  if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
378
416
  throw new Error("An idempotency key is required");
379
- return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId, text }) });
417
+ return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text: text3 }) });
380
418
  }
381
419
  send(accountId, sendId, signal) {
382
420
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
383
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
+ }
384
427
  sendByKey(accountId, key) {
385
428
  if (!key || key.length > 200)
386
429
  throw new Error("Invalid idempotency key");
@@ -389,11 +432,11 @@ var NativeWeChatClient = class {
389
432
  cancelSend(accountId, sendId) {
390
433
  return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
391
434
  }
392
- async upload(accountId, bytes, name, contentType = "application/octet-stream", signal) {
393
- 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))
394
437
  throw new Error("Invalid bounded media upload");
395
438
  const form = new FormData();
396
- 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);
397
440
  return this.request(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal });
398
441
  }
399
442
  sendMedia(accountId, input, key) {
@@ -426,26 +469,21 @@ var NativeWeChatClient = class {
426
469
  }
427
470
  async mediaContent(accountId, assetId, options = {}) {
428
471
  const max = options.maxBytes ?? 128 * 1024 * 1024;
429
- if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range))
472
+ if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range) || options.representation != null && !["original", "preview"].includes(options.representation))
430
473
  throw new Error("Invalid bounded content request");
431
- const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content`, { signal: options.signal, headers: options.range ? { Range: options.range } : {} });
474
+ const query = options.representation === "preview" ? "?representation=preview" : "";
475
+ const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content${query}`, { signal: options.signal, headers: options.range ? { Range: options.range } : {} });
432
476
  const length = response.headers.get("content-length");
433
477
  if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
434
478
  await response.body?.cancel();
435
479
  throw new Error("Native media response exceeds its bound");
436
480
  }
437
- return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), status: response.status };
481
+ return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), sourceSha256: response.headers.get("x-source-sha256"), status: response.status };
438
482
  }
439
- markRead(accountId, threadId, messageIds, expectedVersion) {
440
- if (!messageIds.length || messageIds.length > 200 || messageIds.some((value) => typeof value !== "string" || !value) || typeof expectedVersion !== "string")
441
- throw new Error("Invalid exact-ID read projection");
442
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ messageIds: [...new Set(messageIds)], expectedVersion }) });
443
- }
444
- markReadThrough(accountId, threadId, throughMessageId, expectedVersion) {
445
- id(throughMessageId);
446
- if (typeof expectedVersion !== "string" || !expectedVersion)
483
+ markRead(accountId, threadId2, expectedVersion) {
484
+ if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
447
485
  throw new Error("Read version is required");
448
- return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ throughMessageId, expectedVersion }) });
486
+ return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
449
487
  }
450
488
  events(accountId, after = "0", limit = 100) {
451
489
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
@@ -456,12 +494,27 @@ var NativeWeChatClient = class {
456
494
  return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
457
495
  }
458
496
  async *watch(accountId, after = "0", signal) {
459
- const response = await this.response(`v1/accounts/${id(accountId)}/events/stream?after=${decimal(after)}`, { signal, headers: { Accept: "text/event-stream" } }, true);
460
- if (!response.headers.get("content-type")?.startsWith("text/event-stream"))
461
- throw new Error("Expected native SSE response");
462
- const reader = response.body?.getReader();
463
- if (!reader)
464
- throw new Error("Native SSE has no body");
497
+ const connection = new AbortController();
498
+ const streamSignal = signal ? AbortSignal.any([signal, connection.signal]) : connection.signal;
499
+ const bounded = async (work, milliseconds, reason) => {
500
+ if (streamSignal.aborted)
501
+ throw streamSignal.reason;
502
+ let timer;
503
+ let onAbort = () => {
504
+ };
505
+ const interrupted = new Promise((_resolve, reject) => {
506
+ onAbort = () => reject(streamSignal.reason ?? new Error("Native SSE cancelled"));
507
+ streamSignal.addEventListener("abort", onAbort, { once: true });
508
+ timer = setTimeout(() => connection.abort(new Error(reason)), milliseconds);
509
+ });
510
+ try {
511
+ return await Promise.race([work(), interrupted]);
512
+ } finally {
513
+ clearTimeout(timer);
514
+ streamSignal.removeEventListener("abort", onAbort);
515
+ }
516
+ };
517
+ let reader;
465
518
  const decoder = new TextDecoder();
466
519
  let buffer = "";
467
520
  let data = [];
@@ -469,8 +522,17 @@ var NativeWeChatClient = class {
469
522
  let eventCursor = "";
470
523
  let eventType = "";
471
524
  try {
525
+ const response = await bounded(() => this.response(`v1/accounts/${id(accountId)}/events/stream?after=${decimal(after)}`, { signal: streamSignal, headers: { Accept: "text/event-stream" } }, true), this.timeoutMs, "Native SSE connection timeout");
526
+ if (!response.headers.get("content-type")?.startsWith("text/event-stream")) {
527
+ await response.body?.cancel();
528
+ throw new Error("Expected native SSE response");
529
+ }
530
+ reader = response.body?.getReader();
531
+ if (!reader)
532
+ throw new Error("Native SSE has no body");
472
533
  while (true) {
473
- const { value, done } = await reader.read();
534
+ const activeReader = reader;
535
+ const { value, done } = await bounded(() => activeReader.read(), this.streamIdleTimeoutMs, "Native SSE idle timeout");
474
536
  if (done)
475
537
  break;
476
538
  buffer += decoder.decode(value, { stream: true });
@@ -510,9 +572,12 @@ var NativeWeChatClient = class {
510
572
  }
511
573
  }
512
574
  } finally {
513
- await reader.cancel().catch(() => {
514
- });
515
- reader.releaseLock();
575
+ connection.abort();
576
+ if (reader) {
577
+ await reader.cancel().catch(() => {
578
+ });
579
+ reader.releaseLock();
580
+ }
516
581
  }
517
582
  }
518
583
  };
@@ -533,11 +598,22 @@ function requireNativeIdentity(value) {
533
598
  }
534
599
  }
535
600
  function nativeChildEnvironment(token, environment = process.env) {
536
- 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]]));
537
602
  }
538
603
  function nativeHostTarget(platform = process.platform, arch = process.arch) {
539
- 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");
540
- 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;
541
617
  }
542
618
  async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "", environment = nativeChildEnvironment("")) {
543
619
  const target = nativeHostTarget();
@@ -546,40 +622,48 @@ async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "",
546
622
  if (!candidate) {
547
623
  for (const directory of path.split(delimiter).filter(isAbsolute)) {
548
624
  try {
549
- await access(join(directory, "wx"), constants.X_OK);
550
- candidate = join(directory, "wx");
625
+ await access(join(directory, target.executable), constants.X_OK);
626
+ candidate = join(directory, target.executable);
551
627
  break;
552
628
  } catch {
553
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
+ }
554
639
  }
555
640
  }
556
641
  if (!candidate) throw new Error("native_binary_missing: install native wx separately or configure its absolute path");
557
642
  candidate = await realpath(candidate);
558
- if (candidate.endsWith("/scripts/native-launcher.mjs")) {
643
+ if (candidate.replaceAll("\\", "/").endsWith("/scripts/native-launcher.mjs")) {
559
644
  const root = dirname(dirname(candidate));
560
645
  const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
561
646
  if (pkg.name !== "@agent-wechat/cli" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)) throw new Error("Unsupported native CLI launcher");
562
647
  const manifest = JSON.parse(await readFile(join(root, "native-release.json"), "utf8"));
563
- 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"));
564
649
  if (manifest.version !== pkg.version || !asset || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("Native CLI manifest is incompatible");
565
- 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));
566
651
  const bytes = await readFile(candidate);
567
652
  if (bytes.length !== asset.bytes || createHash2("sha256").update(bytes).digest("hex") !== asset.sha256) throw new Error("Installed native executable checksum mismatch");
568
653
  }
569
654
  const stat = await lstat(candidate);
570
655
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Native executable must be a regular file");
571
656
  await access(candidate, constants.X_OK);
572
- const magic = (await readFile(candidate)).subarray(0, 4);
573
- 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");
574
658
  const { stdout } = await exec(candidate, ["--json", "version"], { env: environment, timeout: 5e3, maxBuffer: 65536 });
575
659
  const version = JSON.parse(stdout);
576
660
  requireNativeIdentity(version);
577
661
  if (version.target !== target.rustTarget) throw new Error("native_binary_incompatible: executable architecture differs from this host");
578
662
  return candidate;
579
663
  }
580
- async function portOccupied(url) {
664
+ async function portOccupied(url2) {
581
665
  return new Promise((resolve, reject) => {
582
- 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)) });
583
667
  socket.setTimeout(1e3);
584
668
  socket.once("connect", () => {
585
669
  socket.destroy();
@@ -596,7 +680,7 @@ async function portOccupied(url) {
596
680
  });
597
681
  });
598
682
  }
599
- async function stopOwnedChild(child) {
683
+ async function stopOwnedChild(child, graceful) {
600
684
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
601
685
  await new Promise((resolve) => {
602
686
  const kill = setTimeout(() => {
@@ -606,7 +690,10 @@ async function stopOwnedChild(child) {
606
690
  clearTimeout(kill);
607
691
  resolve();
608
692
  });
609
- 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");
610
697
  });
611
698
  }
612
699
  async function startNativeHost(config) {
@@ -621,11 +708,11 @@ async function startNativeHost(config) {
621
708
  });
622
709
  }
623
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");
624
- const url = validateNativeBaseUrl(`http://${config.listen ?? "127.0.0.1:6174"}`);
625
- 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");
626
713
  const executable = await resolveNativeExecutable(config.executable, void 0, nativeChildEnvironment(token));
627
- const client = new NativeWeChatClient({ baseUrl: url.href, token, timeoutMs: 1e3 });
628
- 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];
629
716
  if (config.serverConfig) args.push("--server-config", config.serverConfig);
630
717
  const child = spawn(executable, args, { env: nativeChildEnvironment(token), stdio: ["pipe", "ignore", "pipe"], shell: false });
631
718
  child.stderr?.on("data", () => {
@@ -648,10 +735,21 @@ async function startNativeHost(config) {
648
735
  if (exited) throw new Error("Native owned child exited before readiness");
649
736
  try {
650
737
  const status = await client.status();
651
- const dataDir = await realpath(config.dataDir);
738
+ const canonical = await realpath(config.dataDir);
739
+ const dataDir = process.platform === "win32" ? toNamespacedPath(canonical) : canonical;
652
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");
653
741
  requireNativeIdentity(status.version);
654
- 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
+ }));
655
753
  } catch (error) {
656
754
  if (String(error).includes("native_owner_conflict")) throw error;
657
755
  await new Promise((resolve) => setTimeout(resolve, 100));
@@ -709,7 +807,7 @@ function collectWeChatStatusIssues(accounts) {
709
807
  accountId: snapshot.accountId,
710
808
  kind: "runtime",
711
809
  message: "Prepared model handoff was not confirmed by this OpenClaw host; automatic read state remains unchanged.",
712
- fix: "Use a compatible prepared-run SDK hook, or select native.readPolicy=never while retaining explicit local read control."
810
+ fix: "Select native.readPolicy=on_inbound to mark successfully handled conversations read without model hooks, or use a compatible prepared-run SDK hook."
713
811
  });
714
812
  }
715
813
  if ((snapshot.needsReview ?? 0) > 0) {
@@ -980,67 +1078,178 @@ function appendReference(body, reference) {
980
1078
  return body ? `${body}
981
1079
  ${reference}` : reference;
982
1080
  }
983
- function safeDisplayName(name) {
984
- return name.replace(/[\r\n\0]/g, " ").trim() || "attachment";
1081
+ function safeDisplayName(name2) {
1082
+ return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
985
1083
  }
986
1084
  function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
987
1085
  if (!attachment) return originalBody;
988
- const name = safeDisplayName(attachment.filename);
1086
+ const name2 = safeDisplayName(attachment.filename);
989
1087
  if (attachment.status !== "ready" || !attachment.path) {
990
1088
  const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
991
1089
  if (attachment.status === "pending") {
992
- return appendReference(originalBody, `[${label} pending: ${name}]`);
1090
+ return appendReference(originalBody, `[${label} pending: ${name2}]`);
993
1091
  }
994
1092
  if (attachment.status === "expired") {
995
- 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.]`);
996
1094
  }
997
- return appendReference(originalBody, `[${label} unavailable: ${name} (${attachment.status})]`);
1095
+ return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
998
1096
  }
999
1097
  if (attachment.kind === "file") {
1000
- const prefix = originalBody.trim() === name ? "" : originalBody;
1001
- return appendReference(prefix, `[File: ${name}]
1002
- [Local file: ${attachment.path}]`);
1098
+ const prefix = originalBody.trim() === name2 ? "" : originalBody;
1099
+ return appendReference(prefix, `[File: ${name2}]
1100
+ [Local file: <${attachment.path}>]`);
1003
1101
  }
1004
1102
  if (exposeNonVisualPath) {
1005
1103
  if (attachment.kind === "audio") {
1006
- return appendReference(originalBody || "<media:audio>", `[Voice message file: ${attachment.path}]`);
1104
+ return appendReference(originalBody || "<media:audio>", `[Voice message file: <${attachment.path}>]`);
1007
1105
  }
1008
1106
  if (attachment.kind === "video") {
1009
- return appendReference(originalBody || "<media:video>", `[Video file: ${attachment.path}]`);
1107
+ return appendReference(originalBody || "<media:video>", `[Video file: <${attachment.path}>]`);
1010
1108
  }
1011
1109
  }
1012
- const body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
1110
+ let body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
1111
+ if (attachment.kind === "image") body = appendReference(body, `[Image file: <${attachment.path}>]`);
1013
1112
  if (attachment.kind === "image" && attachment.quality === "thumbnail") {
1014
1113
  return appendReference(body, "[Image preview only: thumbnail quality]");
1015
1114
  }
1016
1115
  return body;
1017
1116
  }
1018
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
+
1019
1188
  // src/native-media.ts
1020
1189
  function kind(type) {
1021
1190
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
1022
1191
  }
1023
- var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
1024
- async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3) {
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
+ }
1229
+ async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
1025
1230
  const deadline = Date.now() + timeoutMs;
1026
1231
  let job = initial;
1027
1232
  let delay = 250;
1028
1233
  while (!terminalJobs.has(job.state) && Date.now() < deadline) {
1234
+ signal?.throwIfAborted();
1235
+ assertCurrent?.();
1029
1236
  await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1030
1237
  const remaining = deadline - Date.now();
1031
1238
  if (remaining <= 0) break;
1032
- job = await client.job(accountId, job.id, AbortSignal.timeout(remaining));
1239
+ signal?.throwIfAborted();
1240
+ assertCurrent?.();
1241
+ job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1033
1242
  delay = Math.min(delay * 2, 1e3);
1034
1243
  }
1035
1244
  return job;
1036
1245
  }
1037
- function select(assets, type) {
1246
+ function select(assets, type, quality = "best") {
1038
1247
  const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
1039
1248
  const rank = (asset) => {
1040
1249
  const index = preferred.indexOf(asset.actualVariant ?? asset.variant);
1041
1250
  return index < 0 ? preferred.length : index;
1042
1251
  };
1043
- return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (type !== 34 || (asset.actualVariant ?? asset.variant) === "normal"));
1252
+ return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (quality === "best" ? type !== 34 || (asset.actualVariant ?? asset.variant) === "normal" : (asset.actualVariant ?? asset.variant) === quality));
1044
1253
  }
1045
1254
  async function prepareImageRepresentation(bytes, mime, cfg) {
1046
1255
  const configured = cfg?.agents?.defaults?.imageMaxDimensionPx;
@@ -1054,45 +1263,241 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
1054
1263
  }
1055
1264
  throw Error("Native model image representation exceeds its bound");
1056
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
+ }
1303
+ async function prepareNativeQuote(params) {
1304
+ const { client, account, message } = params;
1305
+ const guard = () => {
1306
+ params.signal?.throwIfAborted();
1307
+ params.assertCurrent?.();
1308
+ };
1309
+ guard();
1310
+ const comment = (message.body ?? "").slice(0, 16384);
1311
+ const unavailable = (state) => ({ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state}; original lookup is confined to this chat]`, pending: false, retryable: state === "unavailable", quote: { state } });
1312
+ await params.beforeMediaAccess?.();
1313
+ guard();
1314
+ let resolution;
1315
+ try {
1316
+ resolution = await client.quotedMessage(account.native.accountId, message.id);
1317
+ } catch (error) {
1318
+ guard();
1319
+ if (!(error instanceof NativeApiError)) throw error;
1320
+ if (error.code === "message_recalled") return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1321
+ return terminalIdentityErrors.has(error.code) ? { ...unavailable("unavailable"), retryable: false, reason: error.code } : unavailable("unavailable");
1322
+ }
1323
+ guard();
1324
+ if (resolution.accountId !== message.accountId || resolution.messageId !== message.id || resolution.threadId !== message.threadId) throw Error("Native quote identity does not match its message");
1325
+ if (resolution.state === "recalled" || resolution.message && nativeRecallInfo(resolution.message)) return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1326
+ if (!message.quote) return unavailable("invalid");
1327
+ if (resolution.state === "embedded") {
1328
+ if (resolution.message != null || (message.quote.type === 1 ? typeof resolution.embeddedText !== "string" || resolution.embeddedText.length > 16384 : message.quote.type !== 3 || resolution.embeddedImage !== true)) throw Error("Invalid embedded quote resolution");
1329
+ const author = { senderId: resolution.senderId ?? null, direction: null };
1330
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) return unavailable("not permitted");
1331
+ guard();
1332
+ const quote2 = { state: "embedded", senderId: resolution.senderId ?? void 0, type: message.quote.type };
1333
+ const prefix2 = `${comment}${comment ? "\n\n" : ""}[Quoted ${message.quote.type === 3 ? "image" : "text"} from ${resolution.senderId ?? "unknown sender"}; embedded in quote, original not stored]
1334
+ `;
1335
+ if (message.quote.type === 1) return { body: `${prefix2}${resolution.embeddedText}
1336
+ [/Quoted message]`, pending: false, quote: quote2 };
1337
+ const beforeMediaAccess2 = async () => {
1338
+ await params.beforeMediaAccess?.();
1339
+ guard();
1340
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1341
+ guard();
1342
+ };
1343
+ const prepared2 = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0 }, beforeMediaAccess: beforeMediaAccess2 });
1344
+ return {
1345
+ ...prepared2,
1346
+ body: `${prefix2}${prepared2.body}
1347
+ [/Quoted message]`,
1348
+ pending: false,
1349
+ quote: quote2,
1350
+ ...prepared2.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1351
+ };
1352
+ }
1353
+ if (resolution.state !== "resolved") {
1354
+ if (!["missing", "invalid", "unsupported", "ambiguous"].includes(resolution.state) || resolution.message != null) throw Error("Invalid native quote resolution");
1355
+ return unavailable(resolution.state);
1356
+ }
1357
+ const original = resolution.message;
1358
+ if (!original || original.accountId !== message.accountId || original.threadId !== message.threadId || original.id === message.id || original.serverId !== message.quote.serverId || original.type !== message.quote.type || ![1, 3].includes(original.type)) throw Error("Native quoted original crosses its message boundary");
1359
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) return unavailable("not permitted");
1360
+ guard();
1361
+ const quote = { state: "resolved", messageId: original.id, senderId: original.senderId ?? void 0, type: original.type };
1362
+ const prefix = `${comment}${comment ? "\n\n" : ""}[Quoted ${original.type === 3 ? "image" : "text"} from ${original.senderId ?? "unknown sender"}; message ${original.id}]
1363
+ `;
1364
+ if (original.type === 1) return { body: `${prefix}${(original.body ?? "").slice(0, 16384)}
1365
+ [/Quoted message]`, pending: false, quote };
1366
+ const beforeMediaAccess = async () => {
1367
+ await params.beforeMediaAccess?.();
1368
+ guard();
1369
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
1370
+ guard();
1371
+ };
1372
+ const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
1373
+ return {
1374
+ ...prepared,
1375
+ body: `${prefix}${prepared.body}
1376
+ [/Quoted message]`,
1377
+ pending: false,
1378
+ quote,
1379
+ ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {},
1380
+ suppliedAssets: prepared.suppliedAssets?.map((asset) => ({ ...asset, sourceMessageId: original.id }))
1381
+ };
1382
+ }
1057
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) {
1058
1403
  const { client, account, message, core } = params;
1059
1404
  const attachmentKind = kind(message.type);
1405
+ const guard = () => {
1406
+ params.signal?.throwIfAborted();
1407
+ params.assertCurrent?.();
1408
+ };
1409
+ guard();
1410
+ if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1411
+ const authorize = async () => {
1412
+ guard();
1413
+ await params.beforeMediaAccess?.();
1414
+ guard();
1415
+ };
1416
+ if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
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);
1419
+ if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
1060
1420
  if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
1061
1421
  const accountId = account.native.accountId;
1062
1422
  const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
1063
1423
  const scopedAssets = async () => {
1424
+ await authorize();
1064
1425
  const items = (await client.mediaAssets(accountId, message.id)).items;
1426
+ guard();
1065
1427
  if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
1066
1428
  return items;
1067
1429
  };
1430
+ const quality = params.quality ?? "best";
1431
+ const transferQuality = quality === "best" && (message.type === 34 || message.type === 43) ? "normal" : quality;
1068
1432
  let assets = await scopedAssets();
1069
- let asset = select(assets, message.type);
1070
- if (!asset && !assets.some((value) => ["expired", "unsupported"].includes(value.state))) {
1433
+ let asset = select(assets, message.type, quality);
1434
+ const relevant = () => assets.filter((value) => transferQuality === "best" || value.variant === transferQuality);
1435
+ const transferCandidate = () => {
1436
+ const order = ["original", "normal", "standard", "thumbnail", "encoded"];
1437
+ return [...relevant()].sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant))[0];
1438
+ };
1439
+ const initialCandidate = transferCandidate();
1440
+ if (!asset && initialCandidate?.byteLength != null && initialCandidate.byteLength > maxBytes) {
1441
+ const attachment2 = { kind: attachmentKind, filename: basename(initialCandidate.filename ?? "attachment"), status: "too_large" };
1442
+ return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false, retryable: false };
1443
+ }
1444
+ let job;
1445
+ let transferError;
1446
+ if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1447
+ await authorize();
1448
+ job = await client.job(accountId, initialCandidate.jobId, params.signal);
1449
+ guard();
1450
+ if (job.accountId !== accountId || job.kind !== "download" || job.assetId != null && job.assetId !== initialCandidate.id) throw Error("Native download job identity does not match its attachment");
1451
+ assets = await scopedAssets();
1452
+ asset = select(assets, message.type, quality);
1453
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1454
+ if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1455
+ }
1456
+ if (!asset && !job && params.download !== false && !["expired", "unsupported", "recalled"].includes(transferCandidate()?.state ?? "")) {
1457
+ await authorize();
1071
1458
  try {
1072
- const job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: message.type === 34 || message.type === 43 ? "normal" : "best" });
1073
- const completed = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4);
1074
- if (["queued", "running"].includes(completed.state)) return { body: `[${attachmentKind} pending]`, pending: true };
1075
- assets = await scopedAssets();
1076
- asset = select(assets, message.type);
1077
- } catch {
1078
- return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true };
1459
+ job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1460
+ guard();
1461
+ job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
1462
+ guard();
1463
+ } catch (error) {
1464
+ guard();
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 };
1467
+ transferError = error;
1079
1468
  }
1469
+ assets = await scopedAssets();
1470
+ asset = select(assets, message.type, quality);
1471
+ if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1472
+ if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
1080
1473
  }
1081
- const filename = basename(asset?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
1474
+ const candidate = transferCandidate();
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, "_");
1082
1476
  if (!asset) {
1083
- const state = assets.find((value) => value.state === "expired") ? "expired" : assets.find((value) => value.state === "unsupported") ? "unsupported" : "unavailable";
1084
- const attachment2 = { kind: attachmentKind, filename, status: state };
1085
- return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1477
+ if (candidate?.state === "recalled" || transferError?.code === "message_recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1478
+ const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
1479
+ const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
1480
+ return {
1481
+ body: renderAttachmentBody("", attachment2),
1482
+ attachment: attachment2,
1483
+ pending: false,
1484
+ jobId: job?.id,
1485
+ retryable: state === "expired" || state === "unsupported" ? false : transferError ? false : job?.retryable ?? params.download === false,
1486
+ reason: transferError?.code ?? (job?.reason && /^[a-z0-9_]{1,80}$/.test(job.reason) ? job.reason : void 0)
1487
+ };
1086
1488
  }
1087
1489
  if (asset.byteLength != null && asset.byteLength > maxBytes) {
1088
1490
  const attachment2 = { kind: attachmentKind, filename, status: "too_large" };
1089
1491
  return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
1090
1492
  }
1091
- const content = await client.mediaContent(accountId, asset.id, { maxBytes });
1493
+ await authorize();
1494
+ const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1495
+ guard();
1092
1496
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
1093
1497
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
1094
1498
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
1095
1499
  if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
1500
+ await authorize();
1096
1501
  const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
1097
1502
  const actual = asset.actualVariant ?? asset.variant;
1098
1503
  const attachment = {
@@ -1105,13 +1510,27 @@ async function prepareNativeAttachment(params) {
1105
1510
  };
1106
1511
  let suppliedPath = saved.path, suppliedMime = mime, suppliedHash = hash, suppliedLength = content.bytes.length;
1107
1512
  let body = renderAttachmentBody("", attachment);
1108
- if (attachmentKind === "image" && params.cfg) {
1109
- const representation = await prepareImageRepresentation(Buffer.from(content.bytes), mime, params.cfg);
1110
- if (representation.preview) {
1513
+ if (attachmentKind === "image" && (params.cfg || mime === "image/x-wxgf")) {
1514
+ let imageBytes = Buffer.from(content.bytes), imageMime = mime;
1515
+ const wxgf = mime === "image/x-wxgf";
1516
+ if (wxgf) {
1517
+ const preview = asset.preview;
1518
+ if (!preview || preview.sourceSha256 !== hash || !/^[a-f0-9]{64}$/.test(preview.sha256) || !Number.isInteger(preview.byteLength) || preview.byteLength <= 0 || preview.byteLength > 16 * 1024 * 1024 || !["image/jpeg", "image/png"].includes(preview.contentType)) throw Error("Native WXGF preview metadata did not verify");
1519
+ await authorize();
1520
+ const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
1521
+ guard();
1522
+ const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
1523
+ if (derived.sourceSha256 !== hash || derivedMime !== preview.contentType || derived.bytes.length !== preview.byteLength || createHash4("sha256").update(derived.bytes).digest("hex") !== preview.sha256) throw Error("Native WXGF preview integrity did not verify");
1524
+ imageBytes = Buffer.from(derived.bytes);
1525
+ imageMime = derivedMime;
1526
+ }
1527
+ const representation = await prepareImageRepresentation(imageBytes, imageMime, params.cfg);
1528
+ if (wxgf || representation.preview) {
1529
+ const extension = representation.mime === "image/png" ? "png" : "jpg";
1111
1530
  const normalized = await saveManagedAttachment({
1112
1531
  data: representation.bytes.toString("base64"),
1113
1532
  mime: representation.mime,
1114
- filename: `${filename}.model.jpg`,
1533
+ filename: `${filename}.model.${extension}`,
1115
1534
  maxBytes: 5 * 1024 * 1024,
1116
1535
  saveMediaBuffer: core.channel.media.saveMediaBuffer
1117
1536
  });
@@ -1119,13 +1538,18 @@ async function prepareNativeAttachment(params) {
1119
1538
  suppliedMime = representation.mime;
1120
1539
  suppliedHash = normalized.sha256;
1121
1540
  suppliedLength = normalized.size;
1122
- body += `
1541
+ body += wxgf ? `
1542
+ [Model image: WXGF still preview; original retained separately; animation and transparency are not represented]` : `
1123
1543
  [Model image: prepared still preview; original retained separately]`;
1124
1544
  }
1125
1545
  }
1546
+ guard();
1126
1547
  return {
1127
1548
  body,
1128
1549
  attachment,
1550
+ jobId: job?.id,
1551
+ retryable: false,
1552
+ variant: actual,
1129
1553
  suppliedAssets: [{
1130
1554
  id: asset.id,
1131
1555
  sha256: suppliedHash,
@@ -1146,7 +1570,7 @@ async function prepareNativeAttachment(params) {
1146
1570
  pending: false
1147
1571
  };
1148
1572
  }
1149
- async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAsVoice = false, key = randomUUID()) {
1573
+ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
1150
1574
  const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
1151
1575
  let bytes;
1152
1576
  let filename;
@@ -1161,7 +1585,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1161
1585
  }
1162
1586
  const reader = response.body?.getReader();
1163
1587
  if (!reader) throw new Error("Native outbound source has no bytes");
1164
- const chunks = [];
1588
+ const chunks2 = [];
1165
1589
  let size = 0;
1166
1590
  try {
1167
1591
  while (true) {
@@ -1169,7 +1593,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1169
1593
  if (done) break;
1170
1594
  size += value.length;
1171
1595
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1172
- chunks.push(value);
1596
+ chunks2.push(value);
1173
1597
  }
1174
1598
  } catch (error) {
1175
1599
  await reader.cancel();
@@ -1179,7 +1603,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1179
1603
  }
1180
1604
  bytes = new Uint8Array(size);
1181
1605
  let offset = 0;
1182
- for (const chunk of chunks) {
1606
+ for (const chunk of chunks2) {
1183
1607
  bytes.set(chunk, offset);
1184
1608
  offset += chunk.length;
1185
1609
  }
@@ -1191,7 +1615,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1191
1615
  try {
1192
1616
  const stat = await file.stat();
1193
1617
  if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
1194
- const chunks = [];
1618
+ const chunks2 = [];
1195
1619
  let size = 0;
1196
1620
  while (true) {
1197
1621
  const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
@@ -1199,9 +1623,9 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1199
1623
  if (!bytesRead) break;
1200
1624
  size += bytesRead;
1201
1625
  if (size > max) throw new Error("Native outbound source exceeds configured size");
1202
- chunks.push(chunk.subarray(0, bytesRead));
1626
+ chunks2.push(chunk.subarray(0, bytesRead));
1203
1627
  }
1204
- bytes = Buffer.concat(chunks, size);
1628
+ bytes = Buffer.concat(chunks2, size);
1205
1629
  } finally {
1206
1630
  await file.close();
1207
1631
  }
@@ -1210,43 +1634,39 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
1210
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";
1211
1635
  }
1212
1636
  if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
1637
+ await beforeSend?.();
1213
1638
  const accountId = account.native.accountId;
1214
1639
  const source = await client.upload(accountId, bytes, filename, mime);
1215
- const input = { threadId, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
1216
- const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
1217
- 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`);
1218
- return `agent-wechat-native:${accountId}:${job.id}`;
1219
- }
1220
-
1221
- // src/native-send.ts
1222
- async function submitNativeText(client, accountId, threadId, text, key, options = {}) {
1223
- let intent = await client.sendText(accountId, threadId, text, key);
1224
- const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1225
- let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1226
- while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1227
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1228
- const remaining = deadline - Date.now();
1229
- if (remaining <= 0) break;
1230
- intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1231
- delay = Math.min(delay * 2, 1e3);
1232
- }
1233
- if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1234
- return intent;
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 };
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 });
1235
1649
  }
1236
1650
 
1237
1651
  // src/outbound-media.ts
1238
- async function sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice = false, idempotencyKey) {
1652
+ async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
1239
1653
  const account = resolveWeChatAccount(cfg);
1240
1654
  if (!account?.native) throw new Error("No native daemon configured");
1241
1655
  const { client } = await ensureNativeHost(account.native);
1242
1656
  const key = idempotencyKey ?? randomUUID2();
1243
1657
  if (!mediaUrl) {
1244
1658
  if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
1245
- const send = await submitNativeText(client, account.native.accountId, to, text, key);
1246
- 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
+ );
1247
1667
  }
1248
1668
  const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
1249
- 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"));
1250
1670
  return result;
1251
1671
  }
1252
1672
 
@@ -1273,8 +1693,17 @@ var NativeConsumerState = class {
1273
1693
  if (parsed.schemaVersion !== 1 || parsed.serverId !== serverId || parsed.accountId !== accountId || !/^\d+$/.test(parsed.cursor) || !parsed.items || typeof parsed.items !== "object") throw new Error("Native consumer identity or schema mismatch; state was preserved");
1274
1694
  for (const [id2, item] of Object.entries(parsed.items)) {
1275
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");
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();
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");
1276
1700
  if (item.state === "dispatching") item.state = "needs_review";
1277
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
+ }
1278
1707
  this.value = parsed;
1279
1708
  this.persist();
1280
1709
  }
@@ -1282,12 +1711,22 @@ var NativeConsumerState = class {
1282
1711
  get cursor() {
1283
1712
  return this.value.cursor;
1284
1713
  }
1714
+ get accountId() {
1715
+ return this.value.accountId;
1716
+ }
1285
1717
  items() {
1286
1718
  return Object.values(this.value.items);
1287
1719
  }
1288
1720
  get(messageId) {
1289
1721
  return this.value.items[messageId];
1290
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
+ }
1291
1730
  persist() {
1292
1731
  const directory = dirname2(this.file);
1293
1732
  mkdirSync(directory, { recursive: true, mode: 448 });
@@ -1331,20 +1770,131 @@ var NativeConsumerState = class {
1331
1770
  accept(cursor, message, origin = "unknown") {
1332
1771
  if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw new Error("Native journal cursor cannot move backward");
1333
1772
  if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
1773
+ let replaced = false;
1334
1774
  this.mutate(() => {
1335
- if (message && !this.value.items[message.id]) {
1336
- const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && ["completed", "ignored"].includes(item.state));
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)) {
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));
1337
1784
  for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
1338
- if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
1339
- this.value.items[message.id] = { message, origin, state: "received" };
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 };
1340
1801
  }
1341
1802
  this.value.cursor = cursor;
1342
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;
1343
1816
  }
1344
- update(messageId, update) {
1817
+ rememberRecall(recall) {
1818
+ const hinted = recall.messageId ? this.get(recall.messageId)?.message : void 0;
1819
+ if (hinted && (hinted.threadId !== recall.threadId || hinted.serverId !== recall.serverId)) {
1820
+ const { messageId: _stale, ...exact } = recall;
1821
+ recall = exact;
1822
+ }
1823
+ const key = nativeRecallIdentity(recall.threadId, recall.serverId);
1824
+ this.value.recalls ??= {};
1825
+ this.value.recalls[key] ??= {
1826
+ threadId: recall.threadId,
1827
+ serverId: recall.serverId,
1828
+ observedAt: recall.observedAt,
1829
+ ...recall.messageId ? { messageId: recall.messageId } : {},
1830
+ ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {}
1831
+ };
1832
+ const affected = /* @__PURE__ */ new Set();
1833
+ for (const item of this.items()) {
1834
+ if (item.message.threadId !== recall.threadId) continue;
1835
+ const target = item.message.serverId === recall.serverId;
1836
+ const quote = item.message.quote?.serverId === recall.serverId;
1837
+ if (!target && !quote) continue;
1838
+ affected.add(item.message.id);
1839
+ item.message = target ? recalledNativeMessage(item.message, recall) : { ...item.message, quote: void 0 };
1840
+ if (target) item.groupContext = false;
1841
+ item.readPending = false;
1842
+ if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
1843
+ else if (target && item.state === "received") item.state = "ignored";
1844
+ item.invalidatedByRecall = true;
1845
+ item.dispatchManifest = void 0;
1846
+ item.manifestHash = void 0;
1847
+ }
1848
+ for (const item of this.items()) {
1849
+ const manifest = item.dispatchManifest;
1850
+ if (item.message.threadId !== recall.threadId || !manifest) continue;
1851
+ const references = [
1852
+ ...manifest.messageIds,
1853
+ ...(manifest.historyInputs ?? []).map((input) => input.id),
1854
+ ...manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
1855
+ ];
1856
+ if (!references.some((id2) => affected.has(id2) || id2 === recall.messageId)) continue;
1857
+ affected.add(item.message.id);
1858
+ item.invalidatedByRecall = true;
1859
+ item.readPending = false;
1860
+ if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
1861
+ item.dispatchManifest = void 0;
1862
+ item.manifestHash = void 0;
1863
+ }
1864
+ return [...affected];
1865
+ }
1866
+ update(messageId, update, revision) {
1345
1867
  const item = this.value.items[messageId];
1868
+ if (revision !== void 0 && item?.revision !== revision) return false;
1346
1869
  if (!item) throw new Error("Unknown native custody message");
1347
1870
  this.mutate(() => Object.assign(this.value.items[messageId], update));
1871
+ return true;
1872
+ }
1873
+ groupHistory(threadId2, beforeId, limit) {
1874
+ const before = this.value.items[beforeId]?.cursor;
1875
+ if (!before || limit <= 0) return [];
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);
1877
+ }
1878
+ bufferGroupMessage(messageId, limit, revision) {
1879
+ const item = this.value.items[messageId];
1880
+ if (revision !== void 0 && item?.revision !== revision) return false;
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");
1882
+ this.mutate(() => {
1883
+ Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
1884
+ const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
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;
1887
+ const retained = history.filter((value) => value.groupContext);
1888
+ for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
1889
+ });
1890
+ return true;
1891
+ }
1892
+ beginDispatch(messageId, dispatchManifest, historyIds = []) {
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");
1894
+ this.mutate(() => {
1895
+ Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest, invalidatedByRecall: false });
1896
+ for (const id2 of historyIds) this.value.items[id2].groupContext = false;
1897
+ });
1348
1898
  }
1349
1899
  /** Synchronous bounded receipt at the SDK's prepared-run callback boundary. */
1350
1900
  preparedCallback(messageIds, readPolicy, previous) {
@@ -1355,6 +1905,7 @@ var NativeConsumerState = class {
1355
1905
  if (!item || item.state !== "dispatching") throw new Error("Prepared input manifest is not in dispatch custody");
1356
1906
  return item;
1357
1907
  });
1908
+ const revisions = items.map((item) => item.revision);
1358
1909
  if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
1359
1910
  const manifestHash = createHash6("sha256").update(JSON.stringify({
1360
1911
  accountId: this.value.accountId,
@@ -1365,8 +1916,15 @@ var NativeConsumerState = class {
1365
1916
  const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
1366
1917
  if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
1367
1918
  this.mutate(() => {
1368
- 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()) {
1369
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");
1370
1928
  if (item.runId && item.runId !== runId) throw new Error("Native prepared manifest was assigned to another run; operator review required");
1371
1929
  Object.assign(item, { state: "handed_off", runId, manifestHash, readPending: readPolicy === "on_model_dispatch", handoffUnconfirmed: false });
1372
1930
  }
@@ -1385,12 +1943,17 @@ var digest = (value) => createHash7("sha256").update(value).digest("hex");
1385
1943
  function prune() {
1386
1944
  for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
1387
1945
  }
1946
+ function nativeSourceMessageSid(message) {
1947
+ return `wechat-native:${message.accountId}:${message.id}:${digest(JSON.stringify([message.threadId, message.serverId ?? null]))}`;
1948
+ }
1388
1949
  function nativeDispatchManifest(params) {
1950
+ const history = params.history ?? [];
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");
1389
1952
  const assets = Object.freeze((params.assets ?? []).map((asset) => {
1390
- 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) || asset.contextRole !== "trigger") throw Error("Invalid native supplied asset manifest");
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");
1391
1954
  return Object.freeze({ ...asset });
1392
1955
  }));
1393
- const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
1956
+ const messageSid = nativeSourceMessageSid(params.message);
1394
1957
  const manifest = {
1395
1958
  schemaVersion: 2,
1396
1959
  runId: randomUUID4(),
@@ -1413,10 +1976,19 @@ function nativeDispatchManifest(params) {
1413
1976
  params.message.type,
1414
1977
  params.message.body,
1415
1978
  params.message.mentions,
1416
- params.message.serverId
1979
+ params.message.serverId,
1980
+ params.message.appMessageType,
1981
+ params.message.quote,
1982
+ params.message.link
1417
1983
  ])),
1418
1984
  role: "trigger"
1419
1985
  })]),
1986
+ ...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
1987
+ id: message.id,
1988
+ version: message.version ?? "",
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])),
1990
+ role: "history"
1991
+ }))) } : {},
1420
1992
  assets,
1421
1993
  policySha256: digest(JSON.stringify(params.policy ?? null)),
1422
1994
  // Exact released 2026.10.2 source-turn identity. Requiring it in the later
@@ -1430,30 +2002,40 @@ function trackNativePreparedInput(manifest, record) {
1430
2002
  if (!enabled || pending.size >= 1024) return;
1431
2003
  pending.set(manifest.runId, { manifest, record, expires: Date.now() + 15 * 60 * 1e3 });
1432
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
+ }
1433
2015
  function promptMatches(prompt, manifest) {
1434
2016
  if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 1024 * 1024) return false;
1435
- const text = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
1436
- const candidates = [text];
2017
+ const text3 = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
2018
+ const candidates = [text3];
1437
2019
  const sender = `Conversation info: \u27E6openclaw:ctx\u27E7
1438
2020
  \`\`\`json
1439
2021
  ${JSON.stringify({ sender: { id: manifest.senderId } })}
1440
2022
  \`\`\`
1441
2023
 
1442
2024
  `;
1443
- if (manifest.threadId.endsWith("@chatroom") && text.startsWith(sender)) candidates.push(text.slice(sender.length));
1444
- 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));
1445
2027
  }
1446
- function normalizedPromptMatches(text, manifest) {
2028
+ function normalizedPromptMatches(text3, manifest) {
1447
2029
  const runtimeAssets = manifest.assets.filter((asset) => asset.runtimeMedia);
1448
2030
  if (runtimeAssets.length) {
1449
2031
  if (runtimeAssets.length !== 1) return false;
1450
2032
  const asset = runtimeAssets[0];
1451
- 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);
1452
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;
1453
- text = text.slice(header[0].length);
1454
- 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\]$/, "");
1455
2037
  }
1456
- return digest(text) === manifest.inputBodySha256;
2038
+ return digest(text3) === manifest.inputBodySha256;
1457
2039
  }
1458
2040
  function currentMessageFacts(messages) {
1459
2041
  if (!Array.isArray(messages) || messages.length > 1e3) return [];
@@ -1514,8 +2096,8 @@ function observeNativeAgentEnd(event, ctx) {
1514
2096
  const blocks = typeof user.content === "string" ? [{ type: "text", text: user.content }] : user.content;
1515
2097
  if (!Array.isArray(blocks) || blocks.length > 32) return;
1516
2098
  if (blocks.some((block) => !["text", "image"].includes(block?.type) || block.type === "text" && typeof block.text !== "string")) return;
1517
- const text = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
1518
- 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;
1519
2101
  const images = blocks.filter((block) => block?.type === "image");
1520
2102
  const expectedImages = m.assets.filter((asset) => asset.role === "model_image");
1521
2103
  if (images.length !== expectedImages.length) return;
@@ -1525,7 +2107,7 @@ function observeNativeAgentEnd(event, ctx) {
1525
2107
  const bytes = Buffer.from(image.data, "base64");
1526
2108
  if (bytes.length !== asset.byteLength || digest(bytes) !== asset.sha256) return;
1527
2109
  }
1528
- 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;
1529
2111
  item.record(m.runId);
1530
2112
  pending.delete(m.runId);
1531
2113
  }
@@ -1543,28 +2125,119 @@ function registerNativePreparedBoundary(api) {
1543
2125
  };
1544
2126
  }
1545
2127
 
2128
+ // src/native-history.ts
2129
+ import { buildHistoryContextFromEntries, buildInboundHistoryFromEntries, DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
2130
+ function nativeGroupHistoryLimit(cfg) {
2131
+ const limit = cfg?.messages?.groupChat?.historyLimit;
2132
+ return Number.isInteger(limit) && limit >= 0 ? Math.min(limit, 200) : DEFAULT_GROUP_HISTORY_LIMIT;
2133
+ }
2134
+ async function transcribeNativeAttachment(prepared, core, cfg) {
2135
+ const attachment = prepared.attachment;
2136
+ if (attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) return prepared;
2137
+ let transcript = "";
2138
+ try {
2139
+ transcript = (await core.mediaUnderstanding.transcribeAudioFile({ filePath: attachment.path, cfg, mime: attachment.mime })).text?.trim() ?? "";
2140
+ } catch {
2141
+ }
2142
+ return { ...prepared, body: `${prepared.body}
2143
+ ${transcript ? `[Voice transcript: ${transcript.slice(0, 1e4)}]` : "[Voice transcription unavailable]"}` };
2144
+ }
2145
+ async function prepareNativeHistory(messages, prepare) {
2146
+ const result = new Array(messages.length);
2147
+ let next = 0;
2148
+ await Promise.all(Array.from({ length: Math.min(3, messages.length) }, async () => {
2149
+ for (let index = next++; index < messages.length; index = next++) {
2150
+ const message = messages[index];
2151
+ let prepared;
2152
+ try {
2153
+ prepared = await prepare(message);
2154
+ } catch {
2155
+ prepared = { body: `[Attachment unavailable for message ${message.id}: retrieval or integrity verification failed]`, pending: false };
2156
+ }
2157
+ if (message.type === 1 && prepared.body.length > 4096) prepared = { ...prepared, body: `${prepared.body.slice(0, 4096)}
2158
+ [Message truncated; use wechat_history or wechat_search for the stored message]` };
2159
+ result[index] = { message, prepared };
2160
+ }
2161
+ }));
2162
+ return result;
2163
+ }
2164
+ function buildNativeContext(history, message, prepared) {
2165
+ const all = [...history, { message, prepared }];
2166
+ const image = history.length ? [...all].reverse().find((item) => item.prepared.attachment?.kind === "image" && item.prepared.attachment.status === "ready" && item.prepared.mediaPath) : void 0;
2167
+ const selected = history.length ? image : all.at(-1);
2168
+ const assets = all.flatMap((item) => (item.prepared.suppliedAssets ?? []).map((asset) => {
2169
+ const contextRole = item.message.id === message.id ? "trigger" : "history";
2170
+ if (item === selected) return { ...asset, contextRole, messageId: item.message.id };
2171
+ return {
2172
+ ...asset,
2173
+ contextRole,
2174
+ messageId: item.message.id,
2175
+ runtimeMedia: false,
2176
+ role: "file_reference",
2177
+ path: asset.sourcePath ?? asset.path,
2178
+ sha256: asset.sourceSha256 ?? asset.sha256,
2179
+ byteLength: asset.sourceByteLength ?? asset.byteLength,
2180
+ mime: asset.sourceMime ?? asset.mime
2181
+ };
2182
+ }));
2183
+ const entries = history.map((item) => ({
2184
+ sender: item.message.senderId,
2185
+ body: item.prepared.body,
2186
+ timestamp: item.message.createdAt ? Date.parse(item.message.createdAt) : void 0,
2187
+ messageId: item.message.id
2188
+ }));
2189
+ const inputBody = entries.length ? buildHistoryContextFromEntries({
2190
+ entries,
2191
+ currentMessage: prepared.body,
2192
+ excludeLast: false,
2193
+ formatEntry: (entry) => `${entry.sender}: ${entry.body}`
2194
+ }) : prepared.body;
2195
+ const attachments = all.flatMap((item) => {
2196
+ const attachment = item.prepared.attachment;
2197
+ return attachment?.status === "ready" && attachment.path ? [{
2198
+ path: attachment.path,
2199
+ filename: attachment.filename,
2200
+ kind: attachment.kind,
2201
+ messageId: item.message.id,
2202
+ sender: item.message.senderId
2203
+ }] : [];
2204
+ });
2205
+ return {
2206
+ inputBody,
2207
+ attachments,
2208
+ assets,
2209
+ history: buildInboundHistoryFromEntries({ entries, limit: entries.length }),
2210
+ mediaPath: selected?.prepared.mediaPath,
2211
+ mediaMime: selected?.prepared.mediaMime
2212
+ };
2213
+ }
2214
+
1546
2215
  // src/native-monitor.ts
1547
2216
  function nativeWasMentioned(message) {
1548
2217
  return message.mentions?.state === "known" && message.mentions.self === true;
1549
2218
  }
1550
2219
  async function projectNativeRead(client, accountId, state) {
1551
- for (const item of state.items().filter((value) => value.readPending && value.runId)) {
2220
+ const pending2 = state.items().filter((value) => value.readPending);
2221
+ const threads = [...new Set(pending2.map((item) => item.message.threadId))];
2222
+ for (const threadId2 of threads) {
1552
2223
  for (let attempt = 0; attempt < 2; attempt++) {
1553
2224
  let cursor;
1554
- let thread;
2225
+ let thread3;
1555
2226
  const seen = /* @__PURE__ */ new Set();
1556
2227
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
1557
2228
  const page = await client.threads(accountId, cursor);
1558
- thread = page.items.find((value) => !isNativeTombstone(value) && value.id === item.message.threadId);
1559
- if (thread || !page.nextCursor) break;
2229
+ thread3 = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
2230
+ if (thread3 || !page.nextCursor) break;
1560
2231
  if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
1561
2232
  seen.add(page.nextCursor);
1562
2233
  cursor = page.nextCursor;
1563
2234
  }
1564
- if (!thread) break;
2235
+ if (!thread3) break;
1565
2236
  try {
1566
- await client.markRead(accountId, thread.id, [item.message.id], thread.readStateVersion);
1567
- state.update(item.message.id, { readPending: false });
2237
+ const receipt = await client.markRead(accountId, thread3.id, thread3.readStateVersion);
2238
+ const notification = receipt?.phoneNotification?.state;
2239
+ const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
2240
+ for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState }, item.revision);
1568
2241
  break;
1569
2242
  } catch (error) {
1570
2243
  if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
@@ -1573,12 +2246,72 @@ async function projectNativeRead(client, accountId, state) {
1573
2246
  }
1574
2247
  }
1575
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
+ }
1576
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) {
1577
2308
  const { client, state, account, message, cfg, core } = params;
2309
+ const readPolicy = account.native?.readPolicy ?? "on_inbound";
1578
2310
  const custody = state.get(message.id);
1579
- 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";
1580
2313
  if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
1581
- state.update(message.id, { state: "ignored" });
2314
+ state.update(message.id, { state: "ignored" }, revision);
1582
2315
  return "ignored";
1583
2316
  }
1584
2317
  const isGroup = message.threadId.endsWith("@chatroom");
@@ -1586,8 +2319,20 @@ async function dispatchNativeMessage(params) {
1586
2319
  const policy = resolveWeChatPolicyContext({ account, cfg, chatId: message.threadId });
1587
2320
  const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "agent-wechat" });
1588
2321
  const commandBody = normalizeWeChatCommandBody(message.type === 1 ? message.body ?? "" : "", { isGroup, wasMentioned });
2322
+ const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
2323
+ const resolve = core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress;
2324
+ const senderAdmission = (senderId) => resolveWeChatIngress({
2325
+ resolve,
2326
+ cfg,
2327
+ accountId: account.accountId,
2328
+ policy,
2329
+ isGroup,
2330
+ chatId: message.threadId,
2331
+ senderId,
2332
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
2333
+ });
1589
2334
  const ingress = await resolveWeChatIngress({
1590
- resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress,
2335
+ resolve,
1591
2336
  cfg,
1592
2337
  accountId: account.accountId,
1593
2338
  policy,
@@ -1595,33 +2340,58 @@ async function dispatchNativeMessage(params) {
1595
2340
  chatId: message.threadId,
1596
2341
  senderId: message.senderId,
1597
2342
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => []),
1598
- command: { allowTextCommands, hasControlCommand: allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg) },
2343
+ command: { allowTextCommands, hasControlCommand },
1599
2344
  mention: { wasMentioned, allowTextCommands }
1600
2345
  });
1601
2346
  const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
1602
2347
  if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
1603
- state.update(message.id, { state: "ignored" });
2348
+ if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
2349
+ state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg), revision);
2350
+ return "ignored";
2351
+ }
2352
+ state.update(message.id, { state: "ignored" }, revision);
1604
2353
  return "ignored";
1605
2354
  }
1606
2355
  let preparedMedia;
1607
- try {
1608
- preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg });
1609
- } catch {
1610
- state.update(message.id, { state: "needs_review" });
1611
- return "needs_review";
1612
- }
2356
+ const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
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 });
1613
2368
  if (preparedMedia.pending) return "pending";
1614
- let inputBody = preparedMedia.body;
1615
- if (preparedMedia.attachment?.kind === "audio" && preparedMedia.attachment.status === "ready" && preparedMedia.attachment.path) {
1616
- try {
1617
- const transcript = await core.mediaUnderstanding.transcribeAudioFile({ filePath: preparedMedia.attachment.path, cfg, mime: preparedMedia.attachment.mime });
1618
- if (transcript.text?.trim()) inputBody += `
1619
- [Voice transcript: ${transcript.text.trim().slice(0, 1e4)}]`;
1620
- else inputBody += "\n[Voice transcription unavailable]";
1621
- } catch {
1622
- inputBody += "\n[Voice transcription unavailable]";
1623
- }
1624
- }
2369
+ preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
2370
+ await recheck([message]);
2371
+ const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
2372
+ const admitted = [];
2373
+ for (const item of buffered) {
2374
+ if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
2375
+ }
2376
+ const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
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
+ }),
2389
+ core,
2390
+ cfg
2391
+ ));
2392
+ await recheck([message, ...admitted]);
2393
+ const context = buildNativeContext(history, message, preparedMedia);
2394
+ const inputBody = context.inputBody;
1625
2395
  const route = core.channel.routing.resolveAgentRoute({
1626
2396
  cfg,
1627
2397
  channel: "agent-wechat",
@@ -1649,14 +2419,17 @@ async function dispatchNativeMessage(params) {
1649
2419
  SenderId: message.senderId,
1650
2420
  Provider: "agent-wechat",
1651
2421
  Surface: "agent-wechat",
1652
- MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
2422
+ MessageSid: nativeSourceMessageSid(message),
1653
2423
  WasMentioned: isGroup ? wasMentioned : void 0,
2424
+ Attachments: context.attachments,
2425
+ InboundHistory: context.history,
1654
2426
  CommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized(commandBody, cfg) ? ingress.commandAccess.authorized : void 0,
1655
2427
  OriginatingChannel: "agent-wechat",
1656
2428
  OriginatingTo: `wechat:${message.threadId}`,
1657
- ...preparedMedia.mediaPath ? { MediaPath: preparedMedia.mediaPath, MediaUrl: preparedMedia.mediaPath, MediaType: preparedMedia.mediaMime } : {}
2429
+ ...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
1658
2430
  });
1659
2431
  await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
2432
+ await recheck([message, ...admitted]);
1660
2433
  const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
1661
2434
  const { onModelSelected, ...prefixOptions } = pipeline;
1662
2435
  const dispatchManifest = nativeDispatchManifest({
@@ -1666,11 +2439,14 @@ async function dispatchNativeMessage(params) {
1666
2439
  agentId: route.agentId,
1667
2440
  channelAccountId: account.accountId,
1668
2441
  policy,
1669
- assets: preparedMedia.suppliedAssets ?? []
2442
+ assets: context.assets,
2443
+ history: admitted
1670
2444
  });
1671
- state.update(message.id, { state: "dispatching", dispatchManifest });
1672
- const callback = message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length ? state.preparedCallback([message.id], account.native?.readPolicy ?? "on_model_dispatch") : void 0;
2445
+ assertCurrent();
2446
+ state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
2447
+ const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
1673
2448
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2449
+ const beforeSend = () => recheck([message, ...admitted]);
1674
2450
  let failed = false;
1675
2451
  let delivery = 0;
1676
2452
  try {
@@ -1680,17 +2456,22 @@ async function dispatchNativeMessage(params) {
1680
2456
  dispatcherOptions: {
1681
2457
  ...prefixOptions,
1682
2458
  deliver: async (payload) => {
2459
+ params.signal?.throwIfAborted();
2460
+ if (state.get(message.id)?.invalidatedByRecall) return;
2461
+ await beforeSend();
1683
2462
  const urls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
1684
2463
  if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
1685
2464
  if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
1686
- for (const url of urls) {
1687
- const key = createHash8("sha256").update(`${account.native.accountId}\0${message.id}\0${delivery++}`).digest("hex");
1688
- 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);
1689
2468
  }
1690
2469
  if (payload.text) {
1691
- 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");
1692
2472
  await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
1693
2473
  }
2474
+ if (urls.length || payload.text) params.onDelivered?.();
1694
2475
  },
1695
2476
  onError: () => {
1696
2477
  failed = true;
@@ -1702,14 +2483,34 @@ async function dispatchNativeMessage(params) {
1702
2483
  // and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
1703
2484
  replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
1704
2485
  });
1705
- const prepared = Boolean(state.get(message.id)?.runId);
1706
- state.update(message.id, { state: failed ? "needs_review" : "completed", handoffUnconfirmed: !prepared });
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";
2489
+ const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
2490
+ state.update(message.id, {
2491
+ state: failed ? "needs_review" : "completed",
2492
+ readPending,
2493
+ handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
2494
+ }, revision);
1707
2495
  return failed ? "needs_review" : "completed";
1708
2496
  } catch {
1709
- state.update(message.id, { state: "needs_review" });
2497
+ if (state.isRecalled(message)) return "ignored";
2498
+ state.update(message.id, { state: "needs_review" }, revision);
1710
2499
  return "needs_review";
1711
2500
  }
1712
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
+ }
1713
2514
  function startNativeReadPump(task, failed, intervalMs = 5e3) {
1714
2515
  let active2 = false, stopped = false;
1715
2516
  const timer = setInterval(() => {
@@ -1737,23 +2538,35 @@ async function startNativeMonitor(opts) {
1737
2538
  const core = getWeChatRuntime();
1738
2539
  let backoff = 250;
1739
2540
  let readTask;
1740
- const flushRead = () => readTask ??= projectNativeRead(host2.client, native.accountId, state).finally(() => {
1741
- readTask = void 0;
1742
- });
2541
+ const flushRead = () => {
2542
+ if (native.readPolicy === "never") return Promise.resolve();
2543
+ return readTask ??= projectNativeRead(host2.client, native.accountId, state).finally(() => {
2544
+ readTask = void 0;
2545
+ });
2546
+ };
1743
2547
  const process2 = async () => {
1744
2548
  const cfg = core.config.current();
1745
2549
  const account = resolveWeChatAccount(cfg, opts.account.accountId) ?? opts.account;
1746
2550
  if (account.backend !== "native" || JSON.stringify(account.native) !== JSON.stringify(native)) throw new Error("Native service configuration changed; restart required before processing");
1747
2551
  for (const item of state.items().filter((value) => value.state === "received")) {
1748
- await dispatchNativeMessage({ client: host2.client, state, account, message: item.message, cfg, core });
2552
+ await dispatchNativeMessage({
2553
+ client: host2.client,
2554
+ state,
2555
+ account,
2556
+ message: item.message,
2557
+ cfg,
2558
+ core,
2559
+ signal: opts.abortSignal,
2560
+ onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
2561
+ });
1749
2562
  }
1750
2563
  try {
1751
2564
  await flushRead();
1752
2565
  } catch {
1753
- opts.log?.error?.("Native read projection is pending; exact input IDs retained for retry");
2566
+ opts.log?.error?.("Native read projection is pending; thread read receipt retained for retry");
1754
2567
  }
1755
- const needsReview = state.items().filter((value) => value.state === "needs_review").length;
1756
- const handoffUnconfirmed = native.readPolicy !== "never" && state.items().some((value) => value.handoffUnconfirmed);
2568
+ const needsReview = state.items().filter((value) => value.state === "needs_review" || value.phoneReadState === "needs_review" || value.phoneReadState === "rejected").length;
2569
+ const handoffUnconfirmed = native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed);
1757
2570
  const accountStatus = await host2.client.account(native.accountId);
1758
2571
  opts.setStatus({
1759
2572
  accountId: account.accountId,
@@ -1764,29 +2577,42 @@ async function startNativeMonitor(opts) {
1764
2577
  handoffUnconfirmed
1765
2578
  });
1766
2579
  };
1767
- 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
+ };
1768
2595
  const stopReadPump = startNativeReadPump(async () => {
1769
2596
  await flushRead();
2597
+ scheduleProcess();
1770
2598
  if (!opts.abortSignal.aborted) opts.setStatus({
1771
2599
  accountId: opts.account.accountId,
1772
- handoffUnconfirmed: native.readPolicy !== "never" && state.items().some((value) => value.handoffUnconfirmed)
2600
+ handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
1773
2601
  });
1774
- }, () => opts.log?.error?.("Native read projection remains pending; exact input IDs retained"));
2602
+ }, () => opts.log?.error?.("Native read projection remains pending; thread read receipts retained"));
1775
2603
  try {
1776
2604
  while (!opts.abortSignal.aborted) {
1777
2605
  try {
1778
2606
  if (state.cursor !== "0") await host2.client.ack(native.accountId, "openclaw", state.cursor);
1779
- await process2();
2607
+ intakeReady = false;
2608
+ await synchronizeNativeEvents(host2.client, native.accountId, state);
2609
+ intakeReady = true;
2610
+ scheduleProcess();
1780
2611
  for await (const event of host2.client.watch(native.accountId, state.cursor, opts.abortSignal)) {
1781
- let message;
1782
- if (event.kind === "message.committed") {
1783
- if (typeof event.data.messageId !== "string") throw new Error("Native committed message identity is missing; cursor retained");
1784
- message = await host2.client.message(native.accountId, event.data.messageId);
1785
- if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
1786
- }
1787
- state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
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() });
1788
2614
  await host2.client.ack(native.accountId, "openclaw", state.cursor);
1789
- await process2();
2615
+ scheduleProcess();
1790
2616
  backoff = 250;
1791
2617
  }
1792
2618
  if (!opts.abortSignal.aborted) throw new Error("Native event stream closed");
@@ -1856,11 +2682,17 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
1856
2682
  }
1857
2683
  return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
1858
2684
  }
1859
- async function sendNativeText(account, threadId, text, key = randomUUID5()) {
2685
+ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
1860
2686
  const native = account.native;
1861
2687
  const { client } = await ensureNativeHost(native);
1862
- const send = await submitNativeText(client, native.accountId, threadId, text, key);
1863
- 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
+ );
1864
2696
  }
1865
2697
  function createNativeLoginTool(account) {
1866
2698
  return {
@@ -1887,10 +2719,10 @@ function createNativeLoginTool(account) {
1887
2719
  }
1888
2720
 
1889
2721
  // src/channel.ts
1890
- async function sendWeChatText(cfg, to, text) {
2722
+ async function sendWeChatText(cfg, to, text3) {
1891
2723
  const account = resolveWeChatAccount(cfg);
1892
2724
  if (!account?.serverUrl) throw new Error("No native daemon configured");
1893
- return sendNativeText(account, to, text);
2725
+ return sendNativeText(account, to, text3);
1894
2726
  }
1895
2727
  var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
1896
2728
  id: "agent-wechat",
@@ -1900,13 +2732,13 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
1900
2732
  messageSendingHooks: true
1901
2733
  },
1902
2734
  outbound: {
1903
- sendText: async ({ cfg, to, text }) => ({
2735
+ sendText: async ({ cfg, to, text: text3 }) => ({
1904
2736
  channel: "agent-wechat",
1905
- messageId: await sendWeChatText(cfg, to, text)
2737
+ ...await sendWeChatText(cfg, to, text3)
1906
2738
  }),
1907
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2739
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
1908
2740
  channel: "agent-wechat",
1909
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2741
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
1910
2742
  })
1911
2743
  }
1912
2744
  });
@@ -2029,7 +2861,7 @@ var wechatPlugin = {
2029
2861
  },
2030
2862
  // ---- Mention adapter ----
2031
2863
  mentions: {
2032
- stripMentions: ({ text, ctx }) => normalizeWeChatCommandBody(text, {
2864
+ stripMentions: ({ text: text3, ctx }) => normalizeWeChatCommandBody(text3, {
2033
2865
  isGroup: ctx.ChatType === "group",
2034
2866
  wasMentioned: ctx.WasMentioned === true
2035
2867
  })
@@ -2048,13 +2880,13 @@ var wechatPlugin = {
2048
2880
  // ---- Outbound adapter for OpenClaw hosts using the outbound hook ----
2049
2881
  outbound: {
2050
2882
  deliveryMode: "direct",
2051
- sendText: async ({ cfg, to, text }) => ({
2883
+ sendText: async ({ cfg, to, text: text3 }) => ({
2052
2884
  channel: "agent-wechat",
2053
- messageId: await sendWeChatText(cfg, to, text)
2885
+ ...await sendWeChatText(cfg, to, text3)
2054
2886
  }),
2055
- sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
2887
+ sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
2056
2888
  channel: "agent-wechat",
2057
- messageId: await sendWeChatMedia(cfg, to, text, mediaUrl, audioAsVoice)
2889
+ ...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
2058
2890
  })
2059
2891
  },
2060
2892
  message: wechatMessageAdapter,
@@ -2235,9 +3067,9 @@ function createNativeFacade(config, dependencies = {}) {
2235
3067
  try {
2236
3068
  const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
2237
3069
  const headers = new Headers();
2238
- for (const name of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
2239
- const value = req.headers[name];
2240
- 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);
2241
3073
  }
2242
3074
  const length = req.headers["content-length"];
2243
3075
  if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
@@ -2261,9 +3093,9 @@ function createNativeFacade(config, dependencies = {}) {
2261
3093
  response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
2262
3094
  }
2263
3095
  res.statusCode = response.status;
2264
- for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
2265
- const value = response.headers.get(name);
2266
- 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);
2267
3099
  }
2268
3100
  res.setHeader("x-content-type-options", "nosniff");
2269
3101
  const reader = response.body?.getReader();
@@ -2310,6 +3142,528 @@ function registerNativeFacade(api, config) {
2310
3142
  return handler.stop;
2311
3143
  }
2312
3144
 
3145
+ // src/native-history-tools.ts
3146
+ import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress2 } from "openclaw/plugin-sdk/channel-ingress-runtime";
3147
+ import { imageResultFromFile } from "openclaw/plugin-sdk/channel-actions";
3148
+ var pageSchema = { limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, cursor: { type: "string", maxLength: 8192, description: "Opaque nextCursor from the same tool and query; do not invent one." } };
3149
+ var names = ["wechat_history", "wechat_search", "wechat_message", "wechat_media"];
3150
+ var archiveNames = ["wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"];
3151
+ var messageIdSchema = { type: "string", minLength: 1, maxLength: 4096 };
3152
+ var qualities = ["best", "original", "standard", "thumbnail", "normal", "encoded"];
3153
+ function object(value) {
3154
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
3155
+ return value;
3156
+ }
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}`);
3159
+ return value.trim();
3160
+ }
3161
+ function threadId(value) {
3162
+ return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
3163
+ }
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
+ };
3175
+ const isQuote = message.type === 49 && message.appMessageType === 57;
3176
+ const isForward = message.type === 49 && message.appMessageType === 19;
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 ?? ""}
3180
+ [Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
3181
+ return {
3182
+ id: message.id,
3183
+ threadId: message.threadId,
3184
+ serverId: message.serverId,
3185
+ senderId: message.senderId,
3186
+ direction: message.direction,
3187
+ type: message.type,
3188
+ appMessageType: message.appMessageType,
3189
+ quote: message.quote,
3190
+ link: isLink ? message.link : void 0,
3191
+ createdAt: message.createdAt,
3192
+ body: body.slice(0, 4e3),
3193
+ bodyTruncated: body.length > 4e3,
3194
+ attachment: message.type !== 1 && !isForward && kind2 !== "unsupported" ? { kind: kind2, retrievalTool, messageId: message.id } : void 0
3195
+ };
3196
+ }
3197
+ function createNativeHistoryTools(context, dependencies = {}) {
3198
+ return createLookupTools(context, dependencies, false);
3199
+ }
3200
+ function createNativeArchiveTools(context, dependencies = {}) {
3201
+ return createLookupTools(context, dependencies, true);
3202
+ }
3203
+ function createLookupTools(context, dependencies, archive) {
3204
+ const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3205
+ if (!currentConfig()) return [];
3206
+ const selected = resolveWeChatAccount(currentConfig());
3207
+ if (!selected?.native || !selected.enabled) return [];
3208
+ const configurationKey = nativeConfigurationKey(selected.native);
3209
+ if (!archive && context.messageChannel !== "agent-wechat") return [];
3210
+ if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
3211
+ if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
3212
+ let currentThread;
3213
+ let requesterSenderId;
3214
+ const target = context.deliveryContext?.channel === "agent-wechat" ? context.deliveryContext.to : void 0;
3215
+ if (!archive) {
3216
+ try {
3217
+ if (context.nativeChannelId) currentThread = threadId(context.nativeChannelId);
3218
+ if (target) {
3219
+ const deliveryThread = threadId(target);
3220
+ if (currentThread && currentThread !== deliveryThread) return [];
3221
+ currentThread ??= deliveryThread;
3222
+ }
3223
+ requesterSenderId = string(context.requesterSenderId, "requester sender ID");
3224
+ } catch {
3225
+ return [];
3226
+ }
3227
+ if (!currentThread) return [];
3228
+ }
3229
+ const selectedId = selected.native.accountId;
3230
+ const tool = (name2, action, description, properties, required = []) => ({
3231
+ name: name2,
3232
+ label: name2.replaceAll("_", " "),
3233
+ description,
3234
+ // The pinned host's tool_call wrapper JSON-serializes image blocks. Its
3235
+ // public direct-only surface preserves actual image content for vision.
3236
+ ...action === "media" ? { catalogMode: "direct-only" } : {},
3237
+ parameters: { type: "object", additionalProperties: false, properties, required },
3238
+ execute: async (_callId, input, signal) => {
3239
+ const args = object(input);
3240
+ const guard = () => {
3241
+ signal?.throwIfAborted();
3242
+ context.assertInvocationCurrent?.();
3243
+ const cfg2 = currentConfig();
3244
+ const account2 = resolveWeChatAccount(cfg2);
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");
3246
+ return { cfg: cfg2, account: account2, native: account2.native };
3247
+ };
3248
+ const { cfg, account, native } = guard();
3249
+ if (Object.keys(args).some((key) => !Object.hasOwn(properties, key))) throw Error(archive ? "Unknown archive lookup argument" : "Unknown lookup argument; WeChat history is restricted to the current conversation");
3250
+ const limit = args.limit ?? 20;
3251
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
3252
+ const cursor = args.cursor === void 0 ? void 0 : string(args.cursor, "cursor", 8192);
3253
+ const query = args.query === void 0 ? void 0 : string(args.query, "query", 256);
3254
+ if (query && Buffer.byteLength(query) > 256) throw Error("Search query exceeds 256 UTF-8 bytes");
3255
+ if (action === "search" && !query) throw Error("A non-empty search query is required");
3256
+ const conversation = archive ? args.threadId === void 0 ? void 0 : threadId(args.threadId) : currentThread;
3257
+ if (action === "history" && !conversation) throw Error("A thread ID is required");
3258
+ const messageId = Object.hasOwn(properties, "messageId") ? string(args.messageId, "message ID") : void 0;
3259
+ if (args.download !== void 0 && typeof args.download !== "boolean") throw Error("download must be a boolean");
3260
+ const quality = args.quality ?? "best";
3261
+ if (!qualities.includes(quality)) throw Error("Invalid media quality");
3262
+ const core = dependencies.core ?? getWeChatRuntime();
3263
+ const allowed = async (sender) => {
3264
+ if (archive) return true;
3265
+ const { cfg: cfg2, account: account2 } = guard();
3266
+ const result2 = await resolveWeChatIngress({
3267
+ resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
3268
+ cfg: cfg2,
3269
+ accountId: account2.accountId,
3270
+ policy: resolveWeChatPolicyContext({ account: account2, cfg: cfg2, chatId: conversation }),
3271
+ isGroup: conversation.endsWith("@chatroom"),
3272
+ chatId: conversation,
3273
+ senderId: sender,
3274
+ readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account2.accountId, env: process.env }).catch(() => [])
3275
+ });
3276
+ return result2.ingress.admission === "dispatch";
3277
+ };
3278
+ if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3279
+ guard();
3280
+ const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3281
+ guard();
3282
+ const accountId = native.accountId;
3283
+ let result;
3284
+ if (action === "threads") {
3285
+ const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
3286
+ const items = [];
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 });
3291
+ }
3292
+ result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
3293
+ } else if (action === "message" || action === "media") {
3294
+ const message = await client.message(accountId, messageId);
3295
+ guard();
3296
+ if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
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");
3298
+ result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
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
+ };
3312
+ let prepared;
3313
+ try {
3314
+ prepared = await prepareNativeAttachment({
3315
+ client,
3316
+ account,
3317
+ message,
3318
+ core,
3319
+ cfg,
3320
+ quality,
3321
+ download: args.download !== false,
3322
+ waitMs: 2e3,
3323
+ signal,
3324
+ assertCurrent: guard,
3325
+ allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
3326
+ beforeMediaAccess: async () => {
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();
3329
+ }
3330
+ });
3331
+ } catch (error) {
3332
+ guard();
3333
+ if (!(error instanceof NativeApiError)) throw error;
3334
+ const details2 = {
3335
+ messageId: message.id,
3336
+ threadId: message.threadId,
3337
+ status: error.code === "message_recalled" ? "recalled" : error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
3338
+ retryable: error.retryable,
3339
+ reason: error.code
3340
+ };
3341
+ return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
3342
+ }
3343
+ guard();
3344
+ if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
3345
+ const details = {
3346
+ messageId: message.id,
3347
+ threadId: message.threadId,
3348
+ status: prepared.reason === "message_recalled" ? "recalled" : prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
3349
+ attachment: prepared.attachment,
3350
+ quote: prepared.quote,
3351
+ link: ["message_recalled", "media_identity_unavailable", "media_source_changed"].includes(prepared.reason ?? "") ? void 0 : message.link,
3352
+ variant: prepared.variant,
3353
+ jobId: prepared.jobId,
3354
+ retryable: prepared.retryable ?? false,
3355
+ reason: prepared.reason,
3356
+ body: prepared.body
3357
+ };
3358
+ const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
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 };
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
+ }
3368
+ return output;
3369
+ }
3370
+ } else {
3371
+ const options = { limit, cursor, ...action === "search" ? { query } : {} };
3372
+ const page = conversation ? await client.messages(accountId, conversation, options) : await client.searchMessages(accountId, options);
3373
+ const items = [];
3374
+ for (const message of page.items) {
3375
+ if (isNativeTombstone(message)) continue;
3376
+ if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
3377
+ if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
3378
+ }
3379
+ result = {
3380
+ ...page,
3381
+ items,
3382
+ scope: "local_profile",
3383
+ searchMode: action === "search" ? "case_insensitive_substring" : void 0,
3384
+ note: archive ? "Account-wide local history may be partial. These reads do not mark threads read or download attachments." : "Local history may be partial. A filtered page can be empty with a nextCursor. These reads do not mark a conversation read or download attachments."
3385
+ };
3386
+ }
3387
+ guard();
3388
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3389
+ }
3390
+ });
3391
+ const querySchema = { type: "string", minLength: 1, maxLength: 256 };
3392
+ const mediaSchema = {
3393
+ messageId: messageIdSchema,
3394
+ download: { type: "boolean", default: true, description: "Set false to use cached media only without requesting a WeChat transfer." },
3395
+ quality: { type: "string", enum: qualities, default: "best", description: "best uses the best cached image, normal video or decoded voice. Explicit variants are strict and may be unavailable." }
3396
+ };
3397
+ if (archive) return [
3398
+ tool(archiveNames[0], "threads", "List or find locally stored threads across the configured WeChat account. Optional case-insensitive query matches thread IDs and display names. Includes groups and peers, with opaque pagination; no read marking.", { query: querySchema, ...pageSchema }),
3399
+ tool(archiveNames[1], "search", "Search locally stored messages across every thread in the configured WeChat account, including other senders. Optional threadId narrows the search. Follow nextCursor with the same query and thread selection; no remote backfill or read marking.", { query: querySchema, threadId: messageIdSchema, ...pageSchema }, ["query"]),
3400
+ tool(archiveNames[2], "history", "Read the locally stored history of any thread in the configured WeChat account, newest first, without sender filtering or read marking. Use wechat_archive_threads to find thread IDs.", { threadId: messageIdSchema, ...pageSchema }, ["threadId"]),
3401
+ tool(archiveNames[3], "message", "Look up a stored message in any thread of the configured WeChat account by its exact opaque message ID. Use wechat_archive_media to retrieve its attachment.", { messageId: messageIdSchema }, ["messageId"]),
3402
+ tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
3403
+ ];
3404
+ return [
3405
+ tool(names[0], "history", "Read the current WeChat conversation's locally stored messages, newest first, beyond the recent inbound buffer. Follow nextCursor for older history; no remote history backfill or read marking.", { ...pageSchema }),
3406
+ tool(names[1], "search", "Search the current WeChat conversation's locally stored message text by case-insensitive substring, beyond buffered messages. Follow nextCursor with the same query in this conversation.", { query: querySchema, ...pageSchema }, ["query"]),
3407
+ tool(names[2], "message", "Look up a stored message by its exact opaque message ID in the current WeChat conversation. Media is described without downloading it; use wechat_media to retrieve its attachment. Long text is bounded.", { messageId: messageIdSchema }, ["messageId"]),
3408
+ tool(names[3], "media", "Retrieve a message attachment or quoted text/image from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
3409
+ ];
3410
+ }
3411
+ function registerNativeHistoryTools(api) {
3412
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeHistoryTools(context) }, { names });
3413
+ api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
3414
+ }
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
+
2313
3667
  // index.ts
2314
3668
  var plugin = {
2315
3669
  id: "agent-wechat",
@@ -2320,9 +3674,13 @@ var plugin = {
2320
3674
  setWeChatRuntime(api.runtime);
2321
3675
  const selected = resolveWeChatAccount(api.config);
2322
3676
  api.registerChannel({ plugin: wechatPlugin });
3677
+ registerNativeHistoryTools(api);
3678
+ registerNativeShareTools(api);
3679
+ registerNativeRecallTools(api);
2323
3680
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
2324
3681
  };
2325
- const stopPreparedBoundary = registerNativePreparedBoundary(api);
3682
+ const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
3683
+ };
2326
3684
  api.registerService({
2327
3685
  id: "agent-wechat-native",
2328
3686
  reload: { configPrefixes: ["plugins.entries.agent-wechat.config", "channels.agent-wechat", "secrets"] },