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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,12 @@ Configure plugin defaults under `plugins.entries.agent-wechat.config` (or the sa
23
23
 
24
24
  The external service remains independently owned. Plugin stop closes its stream and never stops/logs out the daemon or cancels its sends. Remote endpoints require verified HTTPS or an encrypted tunnel terminating at loopback. Set `AGENT_WECHAT_TOKEN` in the Gateway process environment, or use a configured OpenClaw secret provider. The plugin resolves `native.token` through the pinned OpenClaw SDK; `native.tokenFile` is removed. Tokens contain 32–1024 printable ASCII bytes without spaces. An explicit unresolved reference fails startup without falling back to another token. The API token needs read/media/send/login for the selected account, plus read-state only when automatic whole-thread read marking is enabled; do not grant admin solely to run the plugin. Normal sender/DM/group/mention policy remains in `channels.agent-wechat`; defaults remain closed.
25
25
 
26
+ ## Login tool
27
+
28
+ `wechat_login` is available to configured agents through OpenClaw's tool policy, including from other channels while WeChat is offline. Use `{"action":"status"}` to inspect the account, `{"action":"start"}` to start or resume login and display the returned QR image, then `{"action":"wait","timeoutMs":30000}` to check scan/approval completion. Repeated starts reuse the pending operation. For a new operation, `force: true` uses QR instead of saved trust. Waits are bounded and report the operation ID and state. Cancelling a tool call leaves the submitted login operation available for a later call.
29
+
30
+ The tool also accepts `disconnect` (retain saved trust), `logout` (durable native logout) and `forget` with `confirm: true` (remove local authentication, retain history). It stays bound to the configured daemon/account. Apply OpenClaw tool allow/deny policies to control which agents can manage login. The tool is directly callable so the daemon's PNG reaches the model as image content.
31
+
26
32
  ## Native managed mode
27
33
 
28
34
  ```json
@@ -80,6 +86,8 @@ Native text, image, file, video and voice delivery results return actual stored
80
86
 
81
87
  Incoming `message.recalled` events are consumed independently of slow media and model work, including recalls of own or unmentioned messages. The consumer persists the event cursor together with a tombstone, removes recalled body/quote content and attachment references from its custody records, and invalidates pending model proofs. Tombstones survive restart and suppress a late original. Attachment retrieval, transcription, model dispatch and later automatic reply delivery recheck the selected messages so late work cannot reinsert recalled input. A quote's unrelated comment remains available after its recalled original is removed.
82
88
 
89
+ Received quotes use the parsed embedded content delivered by WeChat's server protocol and included in the normal message response's `quote` field. Text and link details render immediately; image metadata can retrieve verified bytes under the reply's own message ID. Other quoted media keeps the current comment and an unavailable attachment status. Local recall suppression and quoted-author admission still apply, including in history tools. A stored original never replaces the embedded excerpt; quote display needs no separate quote endpoint.
90
+
83
91
  If a source-local message ID later names a different exact thread/server target, the consumer admits that new message with a separate host turn and reply key. Completion callbacks, media results and read receipts captured for the old target cannot overwrite the new custody record.
84
92
 
85
93
  Stored-history tools return a recalled placeholder; media tools return terminal `status: "recalled"`, `reason: "message_recalled"`, `retryable: false` without attachment paths or image blocks. Recall does not erase an already submitted model input, an existing OpenClaw transcript, a completed action, or attachment bytes retained by OpenClaw's storage policy. An affected in-flight turn is held for review and its later automatic reply is suppressed; the plugin does not claim retroactive transcript correction.
package/dist/index.js CHANGED
@@ -452,15 +452,6 @@ var NativeWeChatClient = class {
452
452
  validateMessageRecall(message);
453
453
  return message;
454
454
  }
455
- /** Resolve only the stored original in this message's own conversation. */
456
- async quotedMessage(accountId, messageId) {
457
- const quote = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/quote`);
458
- if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous", "recalled"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
459
- throw new Error("Invalid native quote identity");
460
- if (quote.message)
461
- validateMessageRecall(quote.message);
462
- return quote;
463
- }
464
455
  /** Account-wide local search, with the daemon's query-bound opaque cursor. */
465
456
  async searchMessages(accountId, options) {
466
457
  const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
@@ -1337,13 +1328,13 @@ async function nativeOutboundDelivery(client, accountId, threadId2, messageIds,
1337
1328
  async function submitNativeText(client, accountId, threadId2, text3, key, options = {}) {
1338
1329
  let intent = await client.sendText(accountId, threadId2, text3, key);
1339
1330
  const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
1340
- let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1331
+ let delay2 = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1341
1332
  while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1342
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1333
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, deadline - Date.now())));
1343
1334
  const remaining = deadline - Date.now();
1344
1335
  if (remaining <= 0) break;
1345
1336
  intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1346
- delay = Math.min(delay * 2, 1e3);
1337
+ delay2 = Math.min(delay2 * 2, 1e3);
1347
1338
  }
1348
1339
  if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1349
1340
  return intent;
@@ -1396,6 +1387,34 @@ function recalledNativeMessage(message, recall) {
1396
1387
  };
1397
1388
  }
1398
1389
 
1390
+ // src/native-quote.ts
1391
+ function renderEmbeddedQuote(message, permitted = true, imageBody) {
1392
+ const comment = (message.body ?? "").slice(0, 16384);
1393
+ const source = message.quote;
1394
+ const unavailable = (state2) => ({
1395
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state2}]`,
1396
+ image: false,
1397
+ quote: { state: state2, type: source?.type, senderId: source?.senderId ?? void 0 }
1398
+ });
1399
+ if (!source) return unavailable("unavailable");
1400
+ if (!permitted) return unavailable("not permitted");
1401
+ const state = source.state ?? "missing";
1402
+ if (state === "recalled") return unavailable("recalled");
1403
+ if (!["embedded", "missing", "unsupported"].includes(state) || source.embeddedText != null && (typeof source.embeddedText !== "string" || source.embeddedText.length > 16384) || source.embeddedImage != null && typeof source.embeddedImage !== "boolean") return unavailable("invalid");
1404
+ const label = source.type === 1 ? "text" : source.type === 3 ? "image" : source.type === 34 ? "voice" : source.type === 43 ? "video" : source.type === 49 && source.appMessageType === 5 ? "link" : source.type === 49 && source.appMessageType === 6 ? "file" : "message";
1405
+ const image = state === "embedded" && source.type === 3 && source.embeddedImage === true;
1406
+ if (state === "embedded" && !image && !source.embeddedText) return unavailable("invalid");
1407
+ const content = state === "missing" ? "" : image ? imageBody ?? "[Image metadata embedded in quote; image not retrieved]" : source.embeddedText ?? "";
1408
+ const status = state === "unsupported" ? `[${label} attachment unavailable from embedded quote]` : state === "missing" ? "[Embedded quote content unavailable]" : "";
1409
+ return {
1410
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted ${label} from ${source.senderId ?? "unknown sender"}; embedded in reply]
1411
+ ${[content, status].filter(Boolean).join("\n")}
1412
+ [/Quoted message]`,
1413
+ image,
1414
+ quote: { state, type: source.type, senderId: source.senderId ?? void 0 }
1415
+ };
1416
+ }
1417
+
1399
1418
  // src/native-media.ts
1400
1419
  function kind(type) {
1401
1420
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
@@ -1438,11 +1457,11 @@ function mediaSendReceiptIds(job, audioAsVoice) {
1438
1457
  async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial, audioAsVoice, timeoutMs = 1e4) {
1439
1458
  if (initial.accountId !== accountId || initial.threadId !== threadId2 || initial.kind !== "send") throw Error("Native media receipt job identity mismatch; inspect the submitted job and do not resend");
1440
1459
  const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 1e4));
1441
- let job = initial, delay = 100;
1460
+ let job = initial, delay2 = 100;
1442
1461
  while (successfulSendJobs.has(job.state) && !mediaSendReceiptIds(job, audioAsVoice) && Date.now() < deadline) {
1443
1462
  const result = mediaSendResult(job);
1444
1463
  if (["unknown", "not_applicable"].includes(String(result.profileProjection)) || audioAsVoice && Array.isArray(result.chunks) && result.chunks.some((chunk) => !chunk || chunk.state !== "accepted")) break;
1445
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1464
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1446
1465
  const remaining = deadline - Date.now();
1447
1466
  if (remaining <= 0) break;
1448
1467
  let next;
@@ -1453,7 +1472,7 @@ async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial
1453
1472
  }
1454
1473
  if (next.id !== initial.id || next.accountId !== accountId || next.threadId !== threadId2 || next.kind !== "send" || next.sourceId !== initial.sourceId || initial.serverMessageId != null && next.serverMessageId !== initial.serverMessageId) break;
1455
1474
  job = next;
1456
- delay = Math.min(delay * 2, 1e3);
1475
+ delay2 = Math.min(delay2 * 2, 1e3);
1457
1476
  }
1458
1477
  return job;
1459
1478
  }
@@ -1463,11 +1482,11 @@ async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3,
1463
1482
  async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, readStatus) {
1464
1483
  const deadline = Date.now() + timeoutMs;
1465
1484
  let job = initial;
1466
- let delay = 250;
1485
+ let delay2 = 250;
1467
1486
  while (!terminalJobs.has(job.state) && Date.now() < deadline) {
1468
1487
  signal?.throwIfAborted();
1469
1488
  assertCurrent?.();
1470
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1489
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1471
1490
  const remaining = deadline - Date.now();
1472
1491
  if (remaining <= 0) break;
1473
1492
  signal?.throwIfAborted();
@@ -1475,7 +1494,7 @@ async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent,
1475
1494
  const next = await readStatus(job, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1476
1495
  if (!next) break;
1477
1496
  job = next;
1478
- delay = Math.min(delay * 2, 1e3);
1497
+ delay2 = Math.min(delay2 * 2, 1e3);
1479
1498
  }
1480
1499
  return job;
1481
1500
  }
@@ -1551,83 +1570,37 @@ ${prepared.body}`,
1551
1570
  };
1552
1571
  }
1553
1572
  async function prepareNativeQuote(params) {
1554
- const { client, account, message } = params;
1555
- const guard = () => {
1573
+ const { message } = params;
1574
+ const guard2 = () => {
1556
1575
  params.signal?.throwIfAborted();
1557
1576
  params.assertCurrent?.();
1558
1577
  };
1559
- guard();
1560
- const comment = (message.body ?? "").slice(0, 16384);
1561
- 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 } });
1562
- await params.beforeMediaAccess?.();
1563
- guard();
1564
- let resolution;
1565
- try {
1566
- resolution = await client.quotedMessage(account.native.accountId, message.id);
1567
- } catch (error) {
1568
- guard();
1569
- if (!(error instanceof NativeApiError)) throw error;
1570
- if (error.code === "message_recalled") return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1571
- return terminalIdentityErrors.has(error.code) ? { ...unavailable("unavailable"), retryable: false, reason: error.code } : unavailable("unavailable");
1572
- }
1573
- guard();
1574
- if (resolution.accountId !== message.accountId || resolution.messageId !== message.id || resolution.threadId !== message.threadId) throw Error("Native quote identity does not match its message");
1575
- if (resolution.state === "recalled" || resolution.message && nativeRecallInfo(resolution.message)) return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
1576
- if (!message.quote) return unavailable("invalid");
1577
- if (resolution.state === "embedded") {
1578
- 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");
1579
- const author = { senderId: resolution.senderId ?? null, direction: null };
1580
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) return unavailable("not permitted");
1581
- guard();
1582
- const quote2 = { state: "embedded", senderId: resolution.senderId ?? void 0, type: message.quote.type };
1583
- const prefix2 = `${comment}${comment ? "\n\n" : ""}[Quoted ${message.quote.type === 3 ? "image" : "text"} from ${resolution.senderId ?? "unknown sender"}; embedded in quote, original not stored]
1584
- `;
1585
- if (message.quote.type === 1) return { body: `${prefix2}${resolution.embeddedText}
1586
- [/Quoted message]`, pending: false, quote: quote2 };
1587
- const beforeMediaAccess2 = async () => {
1588
- await params.beforeMediaAccess?.();
1589
- guard();
1590
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1591
- guard();
1592
- };
1593
- const prepared2 = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0 }, beforeMediaAccess: beforeMediaAccess2 });
1594
- return {
1595
- ...prepared2,
1596
- body: `${prefix2}${prepared2.body}
1597
- [/Quoted message]`,
1598
- pending: false,
1599
- quote: quote2,
1600
- ...prepared2.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1601
- };
1602
- }
1603
- if (resolution.state !== "resolved") {
1604
- if (!["missing", "invalid", "unsupported", "ambiguous"].includes(resolution.state) || resolution.message != null) throw Error("Invalid native quote resolution");
1605
- return unavailable(resolution.state);
1606
- }
1607
- const original = resolution.message;
1608
- 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");
1609
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) return unavailable("not permitted");
1610
- guard();
1611
- const quote = { state: "resolved", messageId: original.id, senderId: original.senderId ?? void 0, type: original.type };
1612
- const prefix = `${comment}${comment ? "\n\n" : ""}[Quoted ${original.type === 3 ? "image" : "text"} from ${original.senderId ?? "unknown sender"}; message ${original.id}]
1613
- `;
1614
- if (original.type === 1) return { body: `${prefix}${(original.body ?? "").slice(0, 16384)}
1615
- [/Quoted message]`, pending: false, quote };
1578
+ guard2();
1579
+ const author = { senderId: message.quote?.senderId ?? null, direction: message.quote?.direction ?? null };
1580
+ const permitted = !message.quote || !params.allowQuotedMessage || await params.allowQuotedMessage(author);
1581
+ guard2();
1582
+ const rendered = renderEmbeddedQuote(message, permitted);
1583
+ if (!rendered.image) return {
1584
+ body: rendered.body,
1585
+ quote: rendered.quote,
1586
+ pending: false,
1587
+ retryable: false,
1588
+ ...rendered.quote.state === "recalled" ? { reason: "message_recalled" } : {}
1589
+ };
1616
1590
  const beforeMediaAccess = async () => {
1617
1591
  await params.beforeMediaAccess?.();
1618
- guard();
1619
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
1620
- guard();
1592
+ guard2();
1593
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1594
+ guard2();
1621
1595
  };
1622
- const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
1596
+ const prepared = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0, link: void 0 }, beforeMediaAccess });
1597
+ if (prepared.reason === "message_recalled" || terminalIdentityErrors.has(prepared.reason ?? "")) return prepared;
1623
1598
  return {
1624
1599
  ...prepared,
1625
- body: `${prefix}${prepared.body}
1626
- [/Quoted message]`,
1600
+ body: renderEmbeddedQuote(message, true, prepared.body).body,
1601
+ quote: rendered.quote,
1627
1602
  pending: false,
1628
- quote,
1629
- ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {},
1630
- suppliedAssets: prepared.suppliedAssets?.map((asset) => ({ ...asset, sourceMessageId: original.id }))
1603
+ ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1631
1604
  };
1632
1605
  }
1633
1606
  async function prepareNativeAttachment(params) {
@@ -1652,16 +1625,16 @@ async function prepareNativeAttachment(params) {
1652
1625
  async function prepareNativeAttachmentInput(params) {
1653
1626
  const { client, account, message, core } = params;
1654
1627
  const attachmentKind = kind(message.type);
1655
- const guard = () => {
1628
+ const guard2 = () => {
1656
1629
  params.signal?.throwIfAborted();
1657
1630
  params.assertCurrent?.();
1658
1631
  };
1659
- guard();
1632
+ guard2();
1660
1633
  if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1661
1634
  const authorize = async () => {
1662
- guard();
1635
+ guard2();
1663
1636
  await params.beforeMediaAccess?.();
1664
- guard();
1637
+ guard2();
1665
1638
  };
1666
1639
  if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
1667
1640
  if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
@@ -1676,7 +1649,7 @@ async function prepareNativeAttachmentInput(params) {
1676
1649
  const scopedAssets = async () => {
1677
1650
  await authorize();
1678
1651
  const items = (await client.mediaAssets(accountId, message.id)).items;
1679
- guard();
1652
+ guard2();
1680
1653
  if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
1681
1654
  return items;
1682
1655
  };
@@ -1698,8 +1671,8 @@ async function prepareNativeAttachmentInput(params) {
1698
1671
  let transferError;
1699
1672
  if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1700
1673
  await authorize();
1701
- job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard);
1702
- guard();
1674
+ job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard2);
1675
+ guard2();
1703
1676
  if (!job) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: initialCandidate.jobId, retryable: true };
1704
1677
  assets = await scopedAssets();
1705
1678
  asset = select(assets, message.type, quality);
@@ -1710,17 +1683,17 @@ async function prepareNativeAttachmentInput(params) {
1710
1683
  await authorize();
1711
1684
  try {
1712
1685
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1713
- guard();
1686
+ guard2();
1714
1687
  job = await waitNativeMediaStatus(
1715
1688
  job,
1716
1689
  params.waitMs ?? 2e4,
1717
1690
  params.signal,
1718
- guard,
1719
- (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard)
1691
+ guard2,
1692
+ (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard2)
1720
1693
  );
1721
- guard();
1694
+ guard2();
1722
1695
  } catch (error) {
1723
- guard();
1696
+ guard2();
1724
1697
  if (!(error instanceof NativeApiError) || terminalIdentityErrors.has(error.code)) throw error;
1725
1698
  if (error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1726
1699
  transferError = error;
@@ -1766,7 +1739,7 @@ async function prepareNativeAttachmentInput(params) {
1766
1739
  maxBytes,
1767
1740
  expectedBytes: asset.byteLength,
1768
1741
  expectedSha256: asset.sha256 ?? "",
1769
- assertCurrent: guard,
1742
+ assertCurrent: guard2,
1770
1743
  saveMediaStream: params.saveMediaStream ?? saveMediaStream
1771
1744
  });
1772
1745
  } finally {
@@ -1800,7 +1773,7 @@ async function prepareNativeAttachmentInput(params) {
1800
1773
  };
1801
1774
  }
1802
1775
  const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1803
- guard();
1776
+ guard2();
1804
1777
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
1805
1778
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
1806
1779
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
@@ -1826,7 +1799,7 @@ async function prepareNativeAttachmentInput(params) {
1826
1799
  if (!preview || preview.sourceSha256 !== hash || !/^[a-f0-9]{64}$/.test(preview.sha256) || !Number.isInteger(preview.byteLength) || preview.byteLength <= 0 || preview.byteLength > 16 * 1024 * 1024 || !["image/jpeg", "image/png"].includes(preview.contentType)) throw Error("Native WXGF preview metadata did not verify");
1827
1800
  await authorize();
1828
1801
  const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
1829
- guard();
1802
+ guard2();
1830
1803
  const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
1831
1804
  if (derived.sourceSha256 !== hash || derivedMime !== preview.contentType || derived.bytes.length !== preview.byteLength || createHash4("sha256").update(derived.bytes).digest("hex") !== preview.sha256) throw Error("Native WXGF preview integrity did not verify");
1832
1805
  imageBytes = Buffer.from(derived.bytes);
@@ -1851,7 +1824,7 @@ async function prepareNativeAttachmentInput(params) {
1851
1824
  [Model image: prepared still preview; original retained separately]`;
1852
1825
  }
1853
1826
  }
1854
- guard();
1827
+ guard2();
1855
1828
  return {
1856
1829
  body,
1857
1830
  attachment,
@@ -2056,7 +2029,7 @@ var NativeMediaPayloadInspectionError = class extends Error {
2056
2029
  };
2057
2030
  function createWeChatMediaHandlers(send = sendWeChatMedia) {
2058
2031
  const key = (ctx, index = 0) => ctx.deliveryQueueId ? createHash6("sha256").update(JSON.stringify(["wechat-media", ctx.deliveryQueueId, ctx.deliveryPartIndex ?? 0, index])).digest("hex") : void 0;
2059
- const guard = (ctx) => async () => {
2032
+ const guard2 = (ctx) => async () => {
2060
2033
  ctx.signal?.throwIfAborted();
2061
2034
  await ctx.onPlatformSendDispatch?.();
2062
2035
  ctx.signal?.throwIfAborted();
@@ -2065,7 +2038,7 @@ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2065
2038
  return {
2066
2039
  sendMedia: async (ctx) => ({
2067
2040
  channel: "agent-wechat",
2068
- ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard(ctx))
2041
+ ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard2(ctx))
2069
2042
  }),
2070
2043
  sendPayload: async (ctx) => {
2071
2044
  const options = { ...nativePayloadMediaOptions(ctx.payload), forceDocument: ctx.forceDocument };
@@ -2077,7 +2050,7 @@ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2077
2050
  for (const [index, mediaUrl] of (urls.length ? urls : [void 0]).entries()) {
2078
2051
  let delivered;
2079
2052
  try {
2080
- delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard(ctx));
2053
+ delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard2(ctx));
2081
2054
  } catch (error) {
2082
2055
  if (!deliveries.length) throw error;
2083
2056
  throw new NativeMediaPayloadInspectionError(deliveries, error instanceof NativeMediaJobInspectionError ? { jobId: error.jobId, accountId: error.accountId, threadId: error.threadId, idempotencyKey: error.idempotencyKey } : void 0);
@@ -2710,17 +2683,17 @@ async function recheckNativeInputs(client, state, messages) {
2710
2683
  throw new NativeInputInvalidated();
2711
2684
  }
2712
2685
  if (message.quote) {
2713
- const quote = await client.quotedMessage(message.accountId, message.id);
2714
- if (quote.accountId !== message.accountId || quote.threadId !== message.threadId || quote.messageId !== message.id) throw Error("Native quote identity changed");
2715
- if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) {
2686
+ const quote = current.quote;
2687
+ if (quote?.state === "recalled") {
2716
2688
  const affected = state.acceptRecall(state.cursor, {
2717
2689
  threadId: message.threadId,
2718
2690
  serverId: message.quote.serverId,
2719
- observedAt: quote.message && nativeRecallInfo(quote.message)?.observedAt || (/* @__PURE__ */ new Date()).toISOString()
2691
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
2720
2692
  });
2721
2693
  invalidateNativePreparedInputs(message.accountId, affected);
2722
2694
  throw new NativeInputInvalidated();
2723
2695
  }
2696
+ if (!quote || JSON.stringify(quote) !== JSON.stringify(message.quote)) throw Error("Native embedded quote changed; delivery held for review");
2724
2697
  }
2725
2698
  if (!state.isCurrent(message)) throw new NativeInputInvalidated();
2726
2699
  }
@@ -2877,7 +2850,7 @@ async function dispatchNativeMessageInput(params, revision) {
2877
2850
  });
2878
2851
  assertCurrent();
2879
2852
  state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
2880
- const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
2853
+ const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote && [1, 49].includes(preparedMedia.quote.type ?? 0) && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
2881
2854
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2882
2855
  const beforeSend = () => recheck([message, ...admitted]);
2883
2856
  let failed = false;
@@ -3077,43 +3050,69 @@ async function startNativeMonitor(opts) {
3077
3050
 
3078
3051
  // src/native-login.ts
3079
3052
  import { randomUUID as randomUUID5 } from "node:crypto";
3053
+ import { setTimeout as delay } from "node:timers/promises";
3080
3054
  var operations = /* @__PURE__ */ new Map();
3081
3055
  var terminal = /* @__PURE__ */ new Set(["succeeded", "failed", "cancelled", "expired", "rejected", "needs_login"]);
3082
- async function nativeLoginStart(account, options = {}) {
3056
+ function guard(access2) {
3057
+ access2.signal?.throwIfAborted();
3058
+ access2.assertCurrent?.();
3059
+ }
3060
+ function validateOperation(operation, accountId, operationId) {
3061
+ if (operation.accountId !== accountId || operation.kind !== "login" || operationId && operation.id !== operationId) throw Error("Native login operation identity does not match its account");
3062
+ }
3063
+ async function nativeLoginStart(account, options = {}, access2 = {}) {
3064
+ guard(access2);
3083
3065
  const native = account.native;
3084
- const { client } = await ensureNativeHost(native);
3066
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3067
+ guard(access2);
3068
+ const configurationKey = nativeConfigurationKey(native);
3085
3069
  const existing = operations.get(account.accountId);
3086
- if (existing && existing.accountId === native.accountId && !options.force) {
3087
- existing.operation = await client.operation(native.accountId, existing.operation.id);
3088
- if (!terminal.has(existing.operation.state)) return nativeLoginWait(account.accountId, Math.min(options.timeoutMs ?? 3e4, 3e4), true);
3089
- }
3070
+ if (existing && existing.configurationKey === configurationKey) {
3071
+ const refreshed = await client.operation(native.accountId, existing.operation.id);
3072
+ guard(access2);
3073
+ validateOperation(refreshed, native.accountId, existing.operation.id);
3074
+ existing.operation = refreshed;
3075
+ existing.client = client;
3076
+ if (!terminal.has(existing.operation.state)) return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
3077
+ }
3078
+ guard(access2);
3090
3079
  const operation = await client.login(native.accountId, {
3091
3080
  allowQr: true,
3092
3081
  forceQr: options.force === true,
3093
3082
  timeoutSeconds: Math.max(10, Math.min(900, Math.ceil((options.timeoutMs ?? 3e5) / 1e3))),
3094
3083
  computerName: "agent-wechat"
3095
- });
3096
- operations.set(account.accountId, { client, accountId: native.accountId, operation });
3097
- return nativeLoginWait(account.accountId, Math.min(options.timeoutMs ?? 3e4, 3e4), true);
3084
+ }, randomUUID5());
3085
+ validateOperation(operation, native.accountId);
3086
+ operations.set(account.accountId, { client, accountId: native.accountId, configurationKey, operation });
3087
+ guard(access2);
3088
+ return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
3098
3089
  }
3099
- async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
3100
- const active2 = operations.get(accountId);
3101
- if (!active2) return { connected: false, message: "No native login operation; start login first" };
3090
+ async function nativeLoginWait(account, timeoutMs = 3e4, stopAtQr = false, access2 = {}) {
3091
+ guard(access2);
3092
+ const active2 = operations.get(account.accountId);
3093
+ if (!active2 || active2.configurationKey !== nativeConfigurationKey(account.native)) return { connected: false, state: "idle", message: "No native login operation; start login first" };
3102
3094
  const deadline = Date.now() + Math.max(1e3, Math.min(timeoutMs, 3e5));
3103
3095
  while (Date.now() < deadline) {
3104
- active2.operation = await active2.client.operation(active2.accountId, active2.operation.id);
3096
+ guard(access2);
3097
+ const refreshed = await active2.client.operation(active2.accountId, active2.operation.id);
3098
+ guard(access2);
3099
+ validateOperation(refreshed, active2.accountId, active2.operation.id);
3100
+ active2.operation = refreshed;
3105
3101
  const state = active2.operation.state;
3106
- if (state === "succeeded") return { connected: true, message: "Native login succeeded" };
3107
- if (terminal.has(state)) return { connected: false, message: `Native login ${state}` };
3102
+ const result = { connected: state === "succeeded", state, operationId: active2.operation.id };
3103
+ if (state === "succeeded") return { ...result, message: "Native login succeeded" };
3104
+ if (terminal.has(state)) return { ...result, message: `Native login ${state}` };
3105
+ if (stopAtQr && state === "awaiting_approval") return { ...result, message: "Approve native login on your phone" };
3108
3106
  if (stopAtQr && active2.operation.result?.qrAvailable === true) {
3109
3107
  const generation = active2.operation.result?.qrGeneration;
3110
3108
  const png = await active2.client.qr(active2.accountId, active2.operation.id, typeof generation === "number" ? generation : void 0);
3111
- return { connected: false, qrDataUrl: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, message: "Scan the white-background native QR and approve login" };
3109
+ guard(access2);
3110
+ return { ...result, qrDataUrl: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, message: "Scan the white-background native QR and approve login" };
3112
3111
  }
3113
- if (stopAtQr && state === "awaiting_approval") return { connected: false, message: "Approve native login on your phone" };
3114
- await new Promise((resolve) => setTimeout(resolve, 500));
3112
+ await delay(Math.min(500, Math.max(0, deadline - Date.now())), void 0, { signal: access2.signal });
3115
3113
  }
3116
- return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
3114
+ guard(access2);
3115
+ return { connected: false, state: active2.operation.state, operationId: active2.operation.id, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
3117
3116
  }
3118
3117
  async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
3119
3118
  const native = account.native;
@@ -3127,29 +3126,59 @@ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
3127
3126
  { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
3128
3127
  );
3129
3128
  }
3130
- function createNativeLoginTool(account) {
3129
+ function createNativeLoginTool(account, dependencies = {}) {
3130
+ const actions = ["status", "start", "wait", "disconnect", "logout", "forget"];
3131
+ const configurationKey = nativeConfigurationKey(account.native);
3131
3132
  return {
3132
3133
  label: "WeChat Login",
3133
3134
  name: "wechat_login",
3134
- description: "Inspect/start native login, disconnect retaining trust, or request durable logout. Logout commits local inhibition and reports its native server outcome separately; it does not promise saved-device revocation. Explicitly confirmed forget removes local authentication while retaining history. A QR is returned as an image when available.",
3135
- parameters: { type: "object", properties: { action: { type: "string", enum: ["status", "start", "disconnect", "logout", "forget"] }, force: { type: "boolean" }, confirm: { type: "boolean" } }, required: ["action"] },
3136
- execute: async (_id, params) => {
3135
+ catalogMode: "direct-only",
3136
+ description: "Check WeChat status, start or resume native login, or wait for the same login operation. Show the returned QR image to the user to scan and approve on their phone; do not generate a replacement QR. Repeated start reuses an active operation. For a new operation, force uses QR instead of saved trust. timeoutMs bounds the wait. Disconnect retains saved trust. Logout reports its durable native outcome; it does not promise saved-device revocation. Forget requires confirm=true and removes local authentication while retaining history.",
3137
+ parameters: { type: "object", additionalProperties: false, properties: {
3138
+ action: { type: "string", enum: actions },
3139
+ force: { type: "boolean", description: "With start, use QR instead of saved trust for a new login. An active login is still reused." },
3140
+ timeoutMs: { type: "integer", minimum: 1e3, maximum: 3e5, description: "Wait bound in milliseconds; defaults to 30000. Start returns as soon as a QR is available." },
3141
+ confirm: { type: "boolean", description: "Required true for forget." }
3142
+ }, required: ["action"] },
3143
+ execute: async (_id, params, signal) => {
3144
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw Error("Expected login arguments");
3137
3145
  const args = params;
3138
- const native = account.native;
3139
- const { client } = await ensureNativeHost(native);
3140
- if (args.action === "start") {
3141
- const result2 = await nativeLoginStart(account, { force: args.force });
3146
+ if (Object.keys(args).some((key) => !["action", "force", "timeoutMs", "confirm"].includes(key)) || !actions.includes(args.action) || args.force !== void 0 && typeof args.force !== "boolean" || args.confirm !== void 0 && typeof args.confirm !== "boolean" || args.timeoutMs !== void 0 && (!Number.isSafeInteger(args.timeoutMs) || args.timeoutMs < 1e3 || args.timeoutMs > 3e5)) throw Error("Invalid native login arguments");
3147
+ if (args.action === "forget" && args.confirm !== true) throw Error("Forgetting native credentials requires confirm=true");
3148
+ const access2 = { host: dependencies.host, signal, assertCurrent: () => {
3149
+ dependencies.context?.assertInvocationCurrent?.();
3150
+ if (!dependencies.context) return;
3151
+ const context = dependencies.context;
3152
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3153
+ const current = config && resolveWeChatAccount(config);
3154
+ if (!current?.enabled || !current.native || nativeConfigurationKey(current.native) !== configurationKey) throw Error("Native login configuration changed; invoke the tool again");
3155
+ } };
3156
+ guard(access2);
3157
+ if (args.action === "start" || args.action === "wait") {
3158
+ const result2 = args.action === "start" ? await nativeLoginStart(account, { force: args.force, timeoutMs: args.timeoutMs }, access2) : await nativeLoginWait(account, args.timeoutMs, false, access2);
3142
3159
  const content = [{ type: "text", text: result2.message }];
3143
3160
  if (result2.qrDataUrl) content.push({ type: "image", data: result2.qrDataUrl.split(",")[1], mimeType: "image/png" });
3144
- return { content, details: { connected: result2.connected } };
3161
+ return { content, details: { connected: result2.connected, state: result2.state, operationId: result2.operationId } };
3145
3162
  }
3163
+ const native = account.native;
3164
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3165
+ guard(access2);
3146
3166
  const result = args.action === "status" ? await client.account(native.accountId) : args.action === "disconnect" ? await client.disconnect(native.accountId) : args.action === "logout" ? await client.logout(native.accountId, randomUUID5()) : args.action === "forget" && args.confirm === true ? await client.forgetCredentials(native.accountId, randomUUID5(), true) : (() => {
3147
3167
  throw new Error("Unknown native login action");
3148
3168
  })();
3169
+ if (args.action !== "status") operations.delete(account.accountId);
3170
+ guard(access2);
3149
3171
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3150
3172
  }
3151
3173
  };
3152
3174
  }
3175
+ function registerNativeLoginTool(api) {
3176
+ api.registerTool({ contextVersion: 2, create: (context) => {
3177
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3178
+ const account = config && resolveWeChatAccount(config);
3179
+ return account?.enabled && account.native ? [createNativeLoginTool(account, { context })] : [];
3180
+ } }, { names: ["wechat_login"] });
3181
+ }
3153
3182
 
3154
3183
  // src/channel.ts
3155
3184
  async function sendWeChatText(cfg, to, text3) {
@@ -3346,7 +3375,7 @@ var wechatPlugin = {
3346
3375
  loginWithQrWait: async ({ accountId, timeoutMs }) => {
3347
3376
  const account = resolveWeChatAccount(getWeChatRuntime().config.current(), accountId ?? void 0);
3348
3377
  if (!account?.native) return { connected: false, message: "No native daemon configured" };
3349
- return nativeLoginWait(account.accountId, timeoutMs);
3378
+ return nativeLoginWait(account, timeoutMs);
3350
3379
  }
3351
3380
  },
3352
3381
  auth: {
@@ -3372,10 +3401,6 @@ var wechatPlugin = {
3372
3401
  linked: runtime2?.linked ?? false
3373
3402
  })
3374
3403
  },
3375
- agentTools: (({ cfg }) => {
3376
- const account = resolveWeChatAccount(cfg);
3377
- return account?.native ? [createNativeLoginTool(account)] : [];
3378
- }),
3379
3404
  directory: {
3380
3405
  self: async ({ cfg }) => {
3381
3406
  const account = resolveWeChatAccount(cfg);
@@ -3600,7 +3625,7 @@ function string(value, name3, max = 4096) {
3600
3625
  function threadId(value) {
3601
3626
  return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
3602
3627
  }
3603
- function messageResult(message, retrievalTool = "wechat_media") {
3628
+ function messageResult(message, retrievalTool = "wechat_media", quotePermitted = false) {
3604
3629
  if (nativeRecallInfo(message)) return {
3605
3630
  id: message.id,
3606
3631
  threadId: message.threadId,
@@ -3615,8 +3640,15 @@ function messageResult(message, retrievalTool = "wechat_media") {
3615
3640
  const isForward = message.type === 49 && message.appMessageType === 19;
3616
3641
  const isLink = message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link;
3617
3642
  const kind2 = isQuote ? "quote" : isForward ? "forwarded_history" : isLink ? "link_preview" : message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 && (message.appMessageType == null || message.appMessageType === 6) ? "file" : "unsupported";
3618
- const body = message.type === 1 || isForward || isLink ? message.body ?? "" : isQuote ? `${message.body ?? ""}
3619
- [Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
3643
+ const body = message.type === 1 || isForward || isLink ? message.body ?? "" : isQuote ? renderEmbeddedQuote(message, quotePermitted).body : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
3644
+ const quote = message.quote && {
3645
+ serverId: message.quote.serverId,
3646
+ type: message.quote.type,
3647
+ state: quotePermitted ? message.quote.state : "not permitted",
3648
+ senderId: message.quote.senderId,
3649
+ appMessageType: message.quote.appMessageType,
3650
+ embeddedImage: quotePermitted ? message.quote.embeddedImage : void 0
3651
+ };
3620
3652
  return {
3621
3653
  id: message.id,
3622
3654
  threadId: message.threadId,
@@ -3625,7 +3657,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
3625
3657
  direction: message.direction,
3626
3658
  type: message.type,
3627
3659
  appMessageType: message.appMessageType,
3628
- quote: message.quote,
3660
+ quote,
3629
3661
  link: isLink ? message.link : void 0,
3630
3662
  createdAt: message.createdAt,
3631
3663
  body: body.slice(0, 4e3),
@@ -3676,7 +3708,7 @@ function createLookupTools(context, dependencies, archive) {
3676
3708
  parameters: { type: "object", additionalProperties: false, properties, required },
3677
3709
  execute: async (_callId, input, signal) => {
3678
3710
  const args = object(input);
3679
- const guard = () => {
3711
+ const guard2 = () => {
3680
3712
  signal?.throwIfAborted();
3681
3713
  context.assertInvocationCurrent?.();
3682
3714
  const cfg2 = currentConfig();
@@ -3684,7 +3716,7 @@ function createLookupTools(context, dependencies, archive) {
3684
3716
  if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId || nativeConfigurationKey(account2.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
3685
3717
  return { cfg: cfg2, account: account2, native: account2.native };
3686
3718
  };
3687
- const { cfg, account, native } = guard();
3719
+ const { cfg, account, native } = guard2();
3688
3720
  if (Object.keys(args).some((key) => !Object.hasOwn(properties, key))) throw Error(archive ? "Unknown archive lookup argument" : "Unknown lookup argument; WeChat history is restricted to the current conversation");
3689
3721
  const limit = args.limit ?? 20;
3690
3722
  if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
@@ -3701,7 +3733,7 @@ function createLookupTools(context, dependencies, archive) {
3701
3733
  const core = dependencies.core ?? getWeChatRuntime();
3702
3734
  const allowed = async (sender) => {
3703
3735
  if (archive) return true;
3704
- const { cfg: cfg2, account: account2 } = guard();
3736
+ const { cfg: cfg2, account: account2 } = guard2();
3705
3737
  const result2 = await resolveWeChatIngress({
3706
3738
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
3707
3739
  cfg: cfg2,
@@ -3715,10 +3747,15 @@ function createLookupTools(context, dependencies, archive) {
3715
3747
  return result2.ingress.admission === "dispatch";
3716
3748
  };
3717
3749
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3718
- guard();
3750
+ guard2();
3719
3751
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3720
- guard();
3752
+ guard2();
3721
3753
  const accountId = native.accountId;
3754
+ const visibleMessage = async (message) => messageResult(
3755
+ message,
3756
+ archive ? "wechat_archive_media" : "wechat_media",
3757
+ !message.quote || archive || message.quote.direction === "outgoing" || !!message.quote.senderId && await allowed(message.quote.senderId)
3758
+ );
3722
3759
  let result;
3723
3760
  if (action === "threads") {
3724
3761
  const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
@@ -3731,21 +3768,20 @@ function createLookupTools(context, dependencies, archive) {
3731
3768
  result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
3732
3769
  } else if (action === "message" || action === "media") {
3733
3770
  const message = await client.message(accountId, messageId);
3734
- guard();
3771
+ guard2();
3735
3772
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3736
3773
  if (message.id !== messageId || message.accountId !== accountId || !archive && message.threadId !== conversation || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("Message is outside the permitted conversation or sender policy");
3737
- result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
3774
+ if (action === "message") result = await visibleMessage(message);
3738
3775
  if (action === "media") {
3739
3776
  const checkRecall = async () => {
3740
- guard();
3777
+ guard2();
3741
3778
  const latest = await client.message(accountId, messageId);
3742
- guard();
3779
+ guard2();
3743
3780
  if (latest.id !== message.id || latest.accountId !== accountId || latest.threadId !== message.threadId || latest.serverId !== message.serverId) throw Error("Native media message identity changed");
3744
3781
  if (nativeRecallInfo(latest)) throw new NativeApiError(410, "message_recalled", false);
3745
3782
  if (message.quote) {
3746
- const quote = await client.quotedMessage(accountId, message.id);
3747
- guard();
3748
- if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) throw new NativeApiError(410, "message_recalled", false);
3783
+ if (latest.quote?.state === "recalled") throw new NativeApiError(410, "message_recalled", false);
3784
+ if (!latest.quote || JSON.stringify(latest.quote) !== JSON.stringify(message.quote)) throw new NativeApiError(409, "media_source_changed", false);
3749
3785
  }
3750
3786
  };
3751
3787
  let prepared;
@@ -3760,7 +3796,7 @@ function createLookupTools(context, dependencies, archive) {
3760
3796
  download: args.download !== false,
3761
3797
  waitMs: 2e3,
3762
3798
  signal,
3763
- assertCurrent: guard,
3799
+ assertCurrent: guard2,
3764
3800
  allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
3765
3801
  beforeMediaAccess: async () => {
3766
3802
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
@@ -3768,7 +3804,7 @@ function createLookupTools(context, dependencies, archive) {
3768
3804
  }
3769
3805
  });
3770
3806
  } catch (error) {
3771
- guard();
3807
+ guard2();
3772
3808
  if (!(error instanceof NativeApiError)) throw error;
3773
3809
  const details2 = {
3774
3810
  messageId: message.id,
@@ -3779,7 +3815,7 @@ function createLookupTools(context, dependencies, archive) {
3779
3815
  };
3780
3816
  return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
3781
3817
  }
3782
- guard();
3818
+ guard2();
3783
3819
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
3784
3820
  const details = {
3785
3821
  messageId: message.id,
@@ -3796,7 +3832,7 @@ function createLookupTools(context, dependencies, archive) {
3796
3832
  };
3797
3833
  const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
3798
3834
  const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
3799
- guard();
3835
+ guard2();
3800
3836
  try {
3801
3837
  await checkRecall();
3802
3838
  } catch (error) {
@@ -3813,7 +3849,7 @@ function createLookupTools(context, dependencies, archive) {
3813
3849
  for (const message of page.items) {
3814
3850
  if (isNativeTombstone(message)) continue;
3815
3851
  if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
3816
- if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
3852
+ if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(await visibleMessage(message));
3817
3853
  }
3818
3854
  result = {
3819
3855
  ...page,
@@ -3823,7 +3859,7 @@ function createLookupTools(context, dependencies, archive) {
3823
3859
  note: archive ? "Account-wide local history may be partial. These reads do not mark threads read or download attachments." : "Local history may be partial. A filtered page can be empty with a nextCursor. These reads do not mark a conversation read or download attachments."
3824
3860
  };
3825
3861
  }
3826
- guard();
3862
+ guard2();
3827
3863
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3828
3864
  }
3829
3865
  });
@@ -3838,13 +3874,13 @@ function createLookupTools(context, dependencies, archive) {
3838
3874
  tool(archiveNames[1], "search", "Search locally stored messages across every thread in the configured WeChat account, including other senders. Optional threadId narrows the search. Follow nextCursor with the same query and thread selection; no remote backfill or read marking.", { query: querySchema, threadId: messageIdSchema, ...pageSchema }, ["query"]),
3839
3875
  tool(archiveNames[2], "history", "Read the locally stored history of any thread in the configured WeChat account, newest first, without sender filtering or read marking. Use wechat_archive_threads to find thread IDs.", { threadId: messageIdSchema, ...pageSchema }, ["threadId"]),
3840
3876
  tool(archiveNames[3], "message", "Look up a stored message in any thread of the configured WeChat account by its exact opaque message ID. Use wechat_archive_media to retrieve its attachment.", { messageId: messageIdSchema }, ["messageId"]),
3841
- tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
3877
+ tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image/link from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
3842
3878
  ];
3843
3879
  return [
3844
3880
  tool(names[0], "history", "Read the current WeChat conversation's locally stored messages, newest first, beyond the recent inbound buffer. Follow nextCursor for older history; no remote history backfill or read marking.", { ...pageSchema }),
3845
3881
  tool(names[1], "search", "Search the current WeChat conversation's locally stored message text by case-insensitive substring, beyond buffered messages. Follow nextCursor with the same query in this conversation.", { query: querySchema, ...pageSchema }, ["query"]),
3846
3882
  tool(names[2], "message", "Look up a stored message by its exact opaque message ID in the current WeChat conversation. Media is described without downloading it; use wechat_media to retrieve its attachment. Long text is bounded.", { messageId: messageIdSchema }, ["messageId"]),
3847
- tool(names[3], "media", "Retrieve a message attachment or quoted text/image from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
3883
+ tool(names[3], "media", "Retrieve a message attachment or quoted text/image/link from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
3848
3884
  ];
3849
3885
  }
3850
3886
  function registerNativeHistoryTools(api) {
@@ -3910,7 +3946,7 @@ function createNativeShareTools(context, dependencies = {}) {
3910
3946
  ...args.description !== void 0 ? { description: args.description === "" ? "" : text(args.description, "description", 16384, true) } : {},
3911
3947
  ...args.thumbnailUrl !== void 0 ? { thumbnailUrl: url(args.thumbnailUrl, "thumbnail URL") } : {}
3912
3948
  };
3913
- const guard = () => {
3949
+ const guard2 = () => {
3914
3950
  signal?.throwIfAborted();
3915
3951
  context.assertInvocationCurrent?.();
3916
3952
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -3919,7 +3955,7 @@ function createNativeShareTools(context, dependencies = {}) {
3919
3955
  };
3920
3956
  const core = dependencies.core ?? getWeChatRuntime();
3921
3957
  const authorize = async () => {
3922
- const { cfg, account } = guard();
3958
+ const { cfg, account } = guard2();
3923
3959
  const result = await resolveWeChatIngress({
3924
3960
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress3,
3925
3961
  cfg,
@@ -3930,11 +3966,11 @@ function createNativeShareTools(context, dependencies = {}) {
3930
3966
  senderId: requester,
3931
3967
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3932
3968
  });
3933
- guard();
3969
+ guard2();
3934
3970
  if (result.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3935
3971
  };
3936
3972
  await authorize();
3937
- const { native } = guard();
3973
+ const { native } = guard2();
3938
3974
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3939
3975
  await authorize();
3940
3976
  let intent;
@@ -4012,7 +4048,7 @@ function createNativeRecallTools(context, dependencies = {}) {
4012
4048
  const messageId = text2(input.messageId, "message ID");
4013
4049
  const serverId = text2(input.serverId, "server ID");
4014
4050
  if (!/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n) throw Error("Invalid exact server message ID");
4015
- const guard = () => {
4051
+ const guard2 = () => {
4016
4052
  signal?.throwIfAborted();
4017
4053
  context.assertInvocationCurrent?.();
4018
4054
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -4020,7 +4056,7 @@ function createNativeRecallTools(context, dependencies = {}) {
4020
4056
  return { cfg, account, native: account.native };
4021
4057
  };
4022
4058
  const allowed = async () => {
4023
- const { cfg, account } = guard();
4059
+ const { cfg, account } = guard2();
4024
4060
  const core = dependencies.core ?? getWeChatRuntime();
4025
4061
  const ingress = await resolveWeChatIngress({
4026
4062
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress4,
@@ -4032,20 +4068,20 @@ function createNativeRecallTools(context, dependencies = {}) {
4032
4068
  senderId: requester,
4033
4069
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
4034
4070
  });
4035
- guard();
4071
+ guard2();
4036
4072
  if (ingress.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
4037
4073
  };
4038
4074
  await allowed();
4039
- const { native } = guard();
4075
+ const { native } = guard2();
4040
4076
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4041
- guard();
4077
+ guard2();
4042
4078
  const message = await client.message(accountId, messageId);
4043
- guard();
4079
+ guard2();
4044
4080
  const retainedRecall = !!nativeRecallInfo(message);
4045
4081
  if (message.id !== messageId || message.accountId !== accountId || message.threadId !== conversation || message.direction !== "outgoing" && !retainedRecall) throw Error("Recall target is outside this conversation or was not sent by this account");
4046
4082
  if (message.serverId !== serverId) throw Error("The exact server identity does not match this receipt; the local message identity may have been reused");
4047
4083
  await allowed();
4048
- guard();
4084
+ guard2();
4049
4085
  const key = nativeRecallKey(accountId, conversation, messageId, serverId);
4050
4086
  let operation;
4051
4087
  let operationId;
@@ -4075,13 +4111,13 @@ function createNativeRecallTools(context, dependencies = {}) {
4075
4111
  const deadline = Date.now() + Math.max(0, Math.min(dependencies.waitMs ?? 2e3, 1e4));
4076
4112
  while (["queued", "running", "submitting"].includes(operation.state) && Date.now() < deadline) {
4077
4113
  await new Promise((resolve) => setTimeout(resolve, Math.min(250, deadline - Date.now())));
4078
- guard();
4114
+ guard2();
4079
4115
  const remaining = Math.max(1, deadline - Date.now());
4080
4116
  const next = await client.operation(accountId, operation.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
4081
4117
  checkOperation(next);
4082
4118
  operation = next;
4083
4119
  }
4084
- guard();
4120
+ guard2();
4085
4121
  } catch (error) {
4086
4122
  const reason = error instanceof NativeApiError ? error.code : "recall_result_unavailable";
4087
4123
  return result("needs_review", { reason, note: "Inspect the existing operation or repeat only this same message with the same idempotency key. No automatic second submission was made." });
@@ -4127,7 +4163,7 @@ function createNativeGroupTools(context, dependencies = {}) {
4127
4163
  const args = input;
4128
4164
  const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/u.test(value);
4129
4165
  if (!identity(args.groupId) || !args.groupId.endsWith("@chatroom") || args.groupId.length <= 9 || args.limit != null && (!Number.isInteger(args.limit) || Number(args.limit) < 1 || Number(args.limit) > 100) || args.cursor != null && (typeof args.cursor !== "string" || args.cursor.length > 8192) || args.username != null && !identity(args.username) || args.username != null && (args.limit != null || args.cursor != null)) throw Error("Invalid group info selectors");
4130
- const guard = () => {
4166
+ const guard2 = () => {
4131
4167
  signal?.throwIfAborted();
4132
4168
  if (context.senderIsOwner !== true) throw Error("OpenClaw owner authorization is required");
4133
4169
  context.assertInvocationCurrent();
@@ -4135,14 +4171,14 @@ function createNativeGroupTools(context, dependencies = {}) {
4135
4171
  if (!account?.enabled || !account.native || account.accountId !== selected.accountId || account.native.accountId !== accountId || nativeConfigurationKey(account.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
4136
4172
  return account.native;
4137
4173
  };
4138
- const native = guard();
4174
+ const native = guard2();
4139
4175
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4140
- guard();
4176
+ guard2();
4141
4177
  const group = await client.group(accountId, args.groupId, signal);
4142
- guard();
4178
+ guard2();
4143
4179
  if (group.id !== args.groupId || group.roomId !== args.groupId) throw Error("Group identity is invalid");
4144
4180
  const members = typeof args.username === "string" ? await client.groupMember(accountId, args.groupId, args.username, signal) : await client.groupMembers(accountId, args.groupId, { limit: args.limit == null ? 50 : Number(args.limit), cursor: args.cursor }, signal);
4145
- guard();
4181
+ guard2();
4146
4182
  const details = { accountId, group, members, source: "cached", membershipAbsence: "unconfirmed" };
4147
4183
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
4148
4184
  }
@@ -4166,6 +4202,7 @@ var plugin = {
4166
4202
  registerNativeShareTools(api);
4167
4203
  registerNativeRecallTools(api);
4168
4204
  registerNativeGroupTools(api);
4205
+ registerNativeLoginTool(api);
4169
4206
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
4170
4207
  };
4171
4208
  const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "agent-wechat",
3
3
  "contracts": {
4
- "tools": ["wechat_history", "wechat_search", "wechat_message", "wechat_media", "wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media", "wechat_recall", "wechat_send_share", "wechat_group_info"]
4
+ "tools": ["wechat_history", "wechat_search", "wechat_message", "wechat_media", "wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media", "wechat_recall", "wechat_send_share", "wechat_group_info", "wechat_login"]
5
5
  },
6
6
  "toolMetadata": {
7
7
  "wechat_archive_threads": { "optional": true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-wechat/agent-wechat",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",