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

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
@@ -192,7 +192,7 @@ var NativeWeChatClient = class {
192
192
  if (typeof options.token !== "string" || !options.token || /[\r\n]/.test(options.token))
193
193
  throw new Error("Native API bearer token is required");
194
194
  this.token = options.token;
195
- this.fetchImpl = options.fetch ?? fetch;
195
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
196
196
  this.timeoutMs = options.timeoutMs ?? 3e4;
197
197
  this.mediaTimeoutMs = options.mediaTimeoutMs ?? 60 * 6e4;
198
198
  if (!Number.isInteger(this.mediaTimeoutMs) || this.mediaTimeoutMs < 1 || this.mediaTimeoutMs > 60 * 6e4)
@@ -279,9 +279,22 @@ var NativeWeChatClient = class {
279
279
  throw new Error("Account ID or alias is required");
280
280
  return this.request("v1/accounts", { method: "POST", body: JSON.stringify(body) });
281
281
  }
282
+ removeAccount(accountId, confirmAccountId) {
283
+ if (accountId !== confirmAccountId)
284
+ throw new Error("Account confirmation does not match");
285
+ return this.request(`v1/accounts/${id(accountId)}`, { method: "DELETE", body: JSON.stringify({ confirmAccountId }) });
286
+ }
282
287
  updateAccount(accountId, options) {
283
288
  return this.request(`v1/accounts/${id(accountId)}`, { method: "PATCH", body: JSON.stringify(options) });
284
289
  }
290
+ accountSettings(accountId, signal) {
291
+ return this.request(`v1/accounts/${id(accountId)}/settings`, { signal });
292
+ }
293
+ updateAccountSettings(accountId, settings, signal) {
294
+ if (!Number.isSafeInteger(settings.autoDownloadMaxBytes) || settings.autoDownloadMaxBytes < 0 || settings.autoDownloadMaxBytes > NATIVE_MAX_SOURCE_BYTES)
295
+ throw new Error("Invalid automatic download limit");
296
+ return this.request(`v1/accounts/${id(accountId)}/settings`, { method: "PATCH", body: JSON.stringify(settings), signal });
297
+ }
285
298
  capabilities(accountId) {
286
299
  return this.request(`v1/accounts/${id(accountId)}/capabilities`);
287
300
  }
@@ -452,15 +465,6 @@ var NativeWeChatClient = class {
452
465
  validateMessageRecall(message);
453
466
  return message;
454
467
  }
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
468
  /** Account-wide local search, with the daemon's query-bound opaque cursor. */
465
469
  async searchMessages(accountId, options) {
466
470
  const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
@@ -638,7 +642,7 @@ var NativeWeChatClient = class {
638
642
  ack(accountId, consumerId, cursor) {
639
643
  return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
640
644
  }
641
- async *watch(accountId, after = "0", signal) {
645
+ async *watch(accountId, after = "0", signal, options = {}) {
642
646
  const connection = new AbortController();
643
647
  const streamSignal = signal ? AbortSignal.any([signal, connection.signal]) : connection.signal;
644
648
  const bounded = async (work, milliseconds, reason) => {
@@ -680,6 +684,7 @@ var NativeWeChatClient = class {
680
684
  const { value, done } = await bounded(() => activeReader.read(), this.streamIdleTimeoutMs, "Native SSE idle timeout");
681
685
  if (done)
682
686
  break;
687
+ options.onActivity?.();
683
688
  buffer += decoder.decode(value, { stream: true });
684
689
  if (buffer.length > 1024 * 1024)
685
690
  throw new Error("Native SSE frame exceeds its bound");
@@ -1337,13 +1342,13 @@ async function nativeOutboundDelivery(client, accountId, threadId2, messageIds,
1337
1342
  async function submitNativeText(client, accountId, threadId2, text3, key, options = {}) {
1338
1343
  let intent = await client.sendText(accountId, threadId2, text3, key);
1339
1344
  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));
1345
+ let delay2 = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
1341
1346
  while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
1342
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
1347
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, deadline - Date.now())));
1343
1348
  const remaining = deadline - Date.now();
1344
1349
  if (remaining <= 0) break;
1345
1350
  intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
1346
- delay = Math.min(delay * 2, 1e3);
1351
+ delay2 = Math.min(delay2 * 2, 1e3);
1347
1352
  }
1348
1353
  if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
1349
1354
  return intent;
@@ -1396,6 +1401,34 @@ function recalledNativeMessage(message, recall) {
1396
1401
  };
1397
1402
  }
1398
1403
 
1404
+ // src/native-quote.ts
1405
+ function renderEmbeddedQuote(message, permitted = true, imageBody) {
1406
+ const comment = (message.body ?? "").slice(0, 16384);
1407
+ const source = message.quote;
1408
+ const unavailable = (state2) => ({
1409
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state2}]`,
1410
+ image: false,
1411
+ quote: { state: state2, type: source?.type, senderId: source?.senderId ?? void 0 }
1412
+ });
1413
+ if (!source) return unavailable("unavailable");
1414
+ if (!permitted) return unavailable("not permitted");
1415
+ const state = source.state ?? "missing";
1416
+ if (state === "recalled") return unavailable("recalled");
1417
+ 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");
1418
+ 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";
1419
+ const image = state === "embedded" && source.type === 3 && source.embeddedImage === true;
1420
+ if (state === "embedded" && !image && !source.embeddedText) return unavailable("invalid");
1421
+ const content = state === "missing" ? "" : image ? imageBody ?? "[Image metadata embedded in quote; image not retrieved]" : source.embeddedText ?? "";
1422
+ const status = state === "unsupported" ? `[${label} attachment unavailable from embedded quote]` : state === "missing" ? "[Embedded quote content unavailable]" : "";
1423
+ return {
1424
+ body: `${comment}${comment ? "\n\n" : ""}[Quoted ${label} from ${source.senderId ?? "unknown sender"}; embedded in reply]
1425
+ ${[content, status].filter(Boolean).join("\n")}
1426
+ [/Quoted message]`,
1427
+ image,
1428
+ quote: { state, type: source.type, senderId: source.senderId ?? void 0 }
1429
+ };
1430
+ }
1431
+
1399
1432
  // src/native-media.ts
1400
1433
  function kind(type) {
1401
1434
  return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
@@ -1438,11 +1471,11 @@ function mediaSendReceiptIds(job, audioAsVoice) {
1438
1471
  async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial, audioAsVoice, timeoutMs = 1e4) {
1439
1472
  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
1473
  const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 1e4));
1441
- let job = initial, delay = 100;
1474
+ let job = initial, delay2 = 100;
1442
1475
  while (successfulSendJobs.has(job.state) && !mediaSendReceiptIds(job, audioAsVoice) && Date.now() < deadline) {
1443
1476
  const result = mediaSendResult(job);
1444
1477
  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()))));
1478
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1446
1479
  const remaining = deadline - Date.now();
1447
1480
  if (remaining <= 0) break;
1448
1481
  let next;
@@ -1453,7 +1486,7 @@ async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial
1453
1486
  }
1454
1487
  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
1488
  job = next;
1456
- delay = Math.min(delay * 2, 1e3);
1489
+ delay2 = Math.min(delay2 * 2, 1e3);
1457
1490
  }
1458
1491
  return job;
1459
1492
  }
@@ -1463,11 +1496,11 @@ async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3,
1463
1496
  async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent, readStatus) {
1464
1497
  const deadline = Date.now() + timeoutMs;
1465
1498
  let job = initial;
1466
- let delay = 250;
1499
+ let delay2 = 250;
1467
1500
  while (!terminalJobs.has(job.state) && Date.now() < deadline) {
1468
1501
  signal?.throwIfAborted();
1469
1502
  assertCurrent?.();
1470
- await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
1503
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay2, Math.max(0, deadline - Date.now()))));
1471
1504
  const remaining = deadline - Date.now();
1472
1505
  if (remaining <= 0) break;
1473
1506
  signal?.throwIfAborted();
@@ -1475,7 +1508,7 @@ async function waitNativeMediaStatus(initial, timeoutMs, signal, assertCurrent,
1475
1508
  const next = await readStatus(job, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
1476
1509
  if (!next) break;
1477
1510
  job = next;
1478
- delay = Math.min(delay * 2, 1e3);
1511
+ delay2 = Math.min(delay2 * 2, 1e3);
1479
1512
  }
1480
1513
  return job;
1481
1514
  }
@@ -1551,83 +1584,37 @@ ${prepared.body}`,
1551
1584
  };
1552
1585
  }
1553
1586
  async function prepareNativeQuote(params) {
1554
- const { client, account, message } = params;
1555
- const guard = () => {
1587
+ const { message } = params;
1588
+ const guard2 = () => {
1556
1589
  params.signal?.throwIfAborted();
1557
1590
  params.assertCurrent?.();
1558
1591
  };
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 };
1592
+ guard2();
1593
+ const author = { senderId: message.quote?.senderId ?? null, direction: message.quote?.direction ?? null };
1594
+ const permitted = !message.quote || !params.allowQuotedMessage || await params.allowQuotedMessage(author);
1595
+ guard2();
1596
+ const rendered = renderEmbeddedQuote(message, permitted);
1597
+ if (!rendered.image) return {
1598
+ body: rendered.body,
1599
+ quote: rendered.quote,
1600
+ pending: false,
1601
+ retryable: false,
1602
+ ...rendered.quote.state === "recalled" ? { reason: "message_recalled" } : {}
1603
+ };
1616
1604
  const beforeMediaAccess = async () => {
1617
1605
  await params.beforeMediaAccess?.();
1618
- guard();
1619
- if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
1620
- guard();
1606
+ guard2();
1607
+ if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
1608
+ guard2();
1621
1609
  };
1622
- const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
1610
+ const prepared = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0, link: void 0 }, beforeMediaAccess });
1611
+ if (prepared.reason === "message_recalled" || terminalIdentityErrors.has(prepared.reason ?? "")) return prepared;
1623
1612
  return {
1624
1613
  ...prepared,
1625
- body: `${prefix}${prepared.body}
1626
- [/Quoted message]`,
1614
+ body: renderEmbeddedQuote(message, true, prepared.body).body,
1615
+ quote: rendered.quote,
1627
1616
  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 }))
1617
+ ...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
1631
1618
  };
1632
1619
  }
1633
1620
  async function prepareNativeAttachment(params) {
@@ -1652,16 +1639,16 @@ async function prepareNativeAttachment(params) {
1652
1639
  async function prepareNativeAttachmentInput(params) {
1653
1640
  const { client, account, message, core } = params;
1654
1641
  const attachmentKind = kind(message.type);
1655
- const guard = () => {
1642
+ const guard2 = () => {
1656
1643
  params.signal?.throwIfAborted();
1657
1644
  params.assertCurrent?.();
1658
1645
  };
1659
- guard();
1646
+ guard2();
1660
1647
  if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
1661
1648
  const authorize = async () => {
1662
- guard();
1649
+ guard2();
1663
1650
  await params.beforeMediaAccess?.();
1664
- guard();
1651
+ guard2();
1665
1652
  };
1666
1653
  if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
1667
1654
  if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
@@ -1676,7 +1663,7 @@ async function prepareNativeAttachmentInput(params) {
1676
1663
  const scopedAssets = async () => {
1677
1664
  await authorize();
1678
1665
  const items = (await client.mediaAssets(accountId, message.id)).items;
1679
- guard();
1666
+ guard2();
1680
1667
  if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
1681
1668
  return items;
1682
1669
  };
@@ -1698,8 +1685,8 @@ async function prepareNativeAttachmentInput(params) {
1698
1685
  let transferError;
1699
1686
  if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
1700
1687
  await authorize();
1701
- job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard);
1702
- guard();
1688
+ job = await incomingMediaJobStatus(client, accountId, initialCandidate.jobId, initialCandidate.id, params.signal, guard2);
1689
+ guard2();
1703
1690
  if (!job) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: initialCandidate.jobId, retryable: true };
1704
1691
  assets = await scopedAssets();
1705
1692
  asset = select(assets, message.type, quality);
@@ -1710,17 +1697,17 @@ async function prepareNativeAttachmentInput(params) {
1710
1697
  await authorize();
1711
1698
  try {
1712
1699
  job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
1713
- guard();
1700
+ guard2();
1714
1701
  job = await waitNativeMediaStatus(
1715
1702
  job,
1716
1703
  params.waitMs ?? 2e4,
1717
1704
  params.signal,
1718
- guard,
1719
- (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard)
1705
+ guard2,
1706
+ (known, pollSignal) => incomingMediaJobStatus(client, accountId, known.id, initialCandidate?.id, pollSignal, guard2)
1720
1707
  );
1721
- guard();
1708
+ guard2();
1722
1709
  } catch (error) {
1723
- guard();
1710
+ guard2();
1724
1711
  if (!(error instanceof NativeApiError) || terminalIdentityErrors.has(error.code)) throw error;
1725
1712
  if (error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
1726
1713
  transferError = error;
@@ -1766,7 +1753,7 @@ async function prepareNativeAttachmentInput(params) {
1766
1753
  maxBytes,
1767
1754
  expectedBytes: asset.byteLength,
1768
1755
  expectedSha256: asset.sha256 ?? "",
1769
- assertCurrent: guard,
1756
+ assertCurrent: guard2,
1770
1757
  saveMediaStream: params.saveMediaStream ?? saveMediaStream
1771
1758
  });
1772
1759
  } finally {
@@ -1800,7 +1787,7 @@ async function prepareNativeAttachmentInput(params) {
1800
1787
  };
1801
1788
  }
1802
1789
  const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
1803
- guard();
1790
+ guard2();
1804
1791
  const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
1805
1792
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
1806
1793
  const hash = createHash4("sha256").update(content.bytes).digest("hex");
@@ -1826,7 +1813,7 @@ async function prepareNativeAttachmentInput(params) {
1826
1813
  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
1814
  await authorize();
1828
1815
  const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
1829
- guard();
1816
+ guard2();
1830
1817
  const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
1831
1818
  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
1819
  imageBytes = Buffer.from(derived.bytes);
@@ -1851,7 +1838,7 @@ async function prepareNativeAttachmentInput(params) {
1851
1838
  [Model image: prepared still preview; original retained separately]`;
1852
1839
  }
1853
1840
  }
1854
- guard();
1841
+ guard2();
1855
1842
  return {
1856
1843
  body,
1857
1844
  attachment,
@@ -2056,7 +2043,7 @@ var NativeMediaPayloadInspectionError = class extends Error {
2056
2043
  };
2057
2044
  function createWeChatMediaHandlers(send = sendWeChatMedia) {
2058
2045
  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 () => {
2046
+ const guard2 = (ctx) => async () => {
2060
2047
  ctx.signal?.throwIfAborted();
2061
2048
  await ctx.onPlatformSendDispatch?.();
2062
2049
  ctx.signal?.throwIfAborted();
@@ -2065,7 +2052,7 @@ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2065
2052
  return {
2066
2053
  sendMedia: async (ctx) => ({
2067
2054
  channel: "agent-wechat",
2068
- ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard(ctx))
2055
+ ...await send(ctx.cfg, ctx.to, ctx.text, ctx.mediaUrl, ctx.audioAsVoice, key(ctx), { forceDocument: ctx.forceDocument }, guard2(ctx))
2069
2056
  }),
2070
2057
  sendPayload: async (ctx) => {
2071
2058
  const options = { ...nativePayloadMediaOptions(ctx.payload), forceDocument: ctx.forceDocument };
@@ -2077,7 +2064,7 @@ function createWeChatMediaHandlers(send = sendWeChatMedia) {
2077
2064
  for (const [index, mediaUrl] of (urls.length ? urls : [void 0]).entries()) {
2078
2065
  let delivered;
2079
2066
  try {
2080
- delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard(ctx));
2067
+ delivered = await send(ctx.cfg, ctx.to, index === 0 ? ctx.payload.text ?? ctx.text : "", mediaUrl, audioAsVoice, key(ctx, index), options, guard2(ctx));
2081
2068
  } catch (error) {
2082
2069
  if (!deliveries.length) throw error;
2083
2070
  throw new NativeMediaPayloadInspectionError(deliveries, error instanceof NativeMediaJobInspectionError ? { jobId: error.jobId, accountId: error.accountId, threadId: error.threadId, idempotencyKey: error.idempotencyKey } : void 0);
@@ -2710,17 +2697,17 @@ async function recheckNativeInputs(client, state, messages) {
2710
2697
  throw new NativeInputInvalidated();
2711
2698
  }
2712
2699
  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)) {
2700
+ const quote = current.quote;
2701
+ if (quote?.state === "recalled") {
2716
2702
  const affected = state.acceptRecall(state.cursor, {
2717
2703
  threadId: message.threadId,
2718
2704
  serverId: message.quote.serverId,
2719
- observedAt: quote.message && nativeRecallInfo(quote.message)?.observedAt || (/* @__PURE__ */ new Date()).toISOString()
2705
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
2720
2706
  });
2721
2707
  invalidateNativePreparedInputs(message.accountId, affected);
2722
2708
  throw new NativeInputInvalidated();
2723
2709
  }
2710
+ if (!quote || JSON.stringify(quote) !== JSON.stringify(message.quote)) throw Error("Native embedded quote changed; delivery held for review");
2724
2711
  }
2725
2712
  if (!state.isCurrent(message)) throw new NativeInputInvalidated();
2726
2713
  }
@@ -2877,7 +2864,7 @@ async function dispatchNativeMessageInput(params, revision) {
2877
2864
  });
2878
2865
  assertCurrent();
2879
2866
  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;
2867
+ 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
2868
  if (callback) trackNativePreparedInput(dispatchManifest, callback);
2882
2869
  const beforeSend = () => recheck([message, ...admitted]);
2883
2870
  let failed = false;
@@ -3077,43 +3064,69 @@ async function startNativeMonitor(opts) {
3077
3064
 
3078
3065
  // src/native-login.ts
3079
3066
  import { randomUUID as randomUUID5 } from "node:crypto";
3067
+ import { setTimeout as delay } from "node:timers/promises";
3080
3068
  var operations = /* @__PURE__ */ new Map();
3081
3069
  var terminal = /* @__PURE__ */ new Set(["succeeded", "failed", "cancelled", "expired", "rejected", "needs_login"]);
3082
- async function nativeLoginStart(account, options = {}) {
3070
+ function guard(access2) {
3071
+ access2.signal?.throwIfAborted();
3072
+ access2.assertCurrent?.();
3073
+ }
3074
+ function validateOperation(operation, accountId, operationId) {
3075
+ if (operation.accountId !== accountId || operation.kind !== "login" || operationId && operation.id !== operationId) throw Error("Native login operation identity does not match its account");
3076
+ }
3077
+ async function nativeLoginStart(account, options = {}, access2 = {}) {
3078
+ guard(access2);
3083
3079
  const native = account.native;
3084
- const { client } = await ensureNativeHost(native);
3080
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3081
+ guard(access2);
3082
+ const configurationKey = nativeConfigurationKey(native);
3085
3083
  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
- }
3084
+ if (existing && existing.configurationKey === configurationKey) {
3085
+ const refreshed = await client.operation(native.accountId, existing.operation.id);
3086
+ guard(access2);
3087
+ validateOperation(refreshed, native.accountId, existing.operation.id);
3088
+ existing.operation = refreshed;
3089
+ existing.client = client;
3090
+ if (!terminal.has(existing.operation.state)) return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
3091
+ }
3092
+ guard(access2);
3090
3093
  const operation = await client.login(native.accountId, {
3091
3094
  allowQr: true,
3092
3095
  forceQr: options.force === true,
3093
3096
  timeoutSeconds: Math.max(10, Math.min(900, Math.ceil((options.timeoutMs ?? 3e5) / 1e3))),
3094
3097
  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);
3098
+ }, randomUUID5());
3099
+ validateOperation(operation, native.accountId);
3100
+ operations.set(account.accountId, { client, accountId: native.accountId, configurationKey, operation });
3101
+ guard(access2);
3102
+ return nativeLoginWait(account, Math.min(options.timeoutMs ?? 3e4, 3e4), true, access2);
3098
3103
  }
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" };
3104
+ async function nativeLoginWait(account, timeoutMs = 3e4, stopAtQr = false, access2 = {}) {
3105
+ guard(access2);
3106
+ const active2 = operations.get(account.accountId);
3107
+ if (!active2 || active2.configurationKey !== nativeConfigurationKey(account.native)) return { connected: false, state: "idle", message: "No native login operation; start login first" };
3102
3108
  const deadline = Date.now() + Math.max(1e3, Math.min(timeoutMs, 3e5));
3103
3109
  while (Date.now() < deadline) {
3104
- active2.operation = await active2.client.operation(active2.accountId, active2.operation.id);
3110
+ guard(access2);
3111
+ const refreshed = await active2.client.operation(active2.accountId, active2.operation.id);
3112
+ guard(access2);
3113
+ validateOperation(refreshed, active2.accountId, active2.operation.id);
3114
+ active2.operation = refreshed;
3105
3115
  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}` };
3116
+ const result = { connected: state === "succeeded", state, operationId: active2.operation.id };
3117
+ if (state === "succeeded") return { ...result, message: "Native login succeeded" };
3118
+ if (terminal.has(state)) return { ...result, message: `Native login ${state}` };
3119
+ if (stopAtQr && state === "awaiting_approval") return { ...result, message: "Approve native login on your phone" };
3108
3120
  if (stopAtQr && active2.operation.result?.qrAvailable === true) {
3109
3121
  const generation = active2.operation.result?.qrGeneration;
3110
3122
  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" };
3123
+ guard(access2);
3124
+ return { ...result, qrDataUrl: `data:image/png;base64,${Buffer.from(png).toString("base64")}`, message: "Scan the white-background native QR and approve login" };
3112
3125
  }
3113
- if (stopAtQr && state === "awaiting_approval") return { connected: false, message: "Approve native login on your phone" };
3114
- await new Promise((resolve) => setTimeout(resolve, 500));
3126
+ await delay(Math.min(500, Math.max(0, deadline - Date.now())), void 0, { signal: access2.signal });
3115
3127
  }
3116
- return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
3128
+ guard(access2);
3129
+ 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
3130
  }
3118
3131
  async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
3119
3132
  const native = account.native;
@@ -3127,29 +3140,59 @@ async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
3127
3140
  { sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
3128
3141
  );
3129
3142
  }
3130
- function createNativeLoginTool(account) {
3143
+ function createNativeLoginTool(account, dependencies = {}) {
3144
+ const actions = ["status", "start", "wait", "disconnect", "logout", "forget"];
3145
+ const configurationKey = nativeConfigurationKey(account.native);
3131
3146
  return {
3132
3147
  label: "WeChat Login",
3133
3148
  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) => {
3149
+ catalogMode: "direct-only",
3150
+ 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.",
3151
+ parameters: { type: "object", additionalProperties: false, properties: {
3152
+ action: { type: "string", enum: actions },
3153
+ force: { type: "boolean", description: "With start, use QR instead of saved trust for a new login. An active login is still reused." },
3154
+ timeoutMs: { type: "integer", minimum: 1e3, maximum: 3e5, description: "Wait bound in milliseconds; defaults to 30000. Start returns as soon as a QR is available." },
3155
+ confirm: { type: "boolean", description: "Required true for forget." }
3156
+ }, required: ["action"] },
3157
+ execute: async (_id, params, signal) => {
3158
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw Error("Expected login arguments");
3137
3159
  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 });
3160
+ 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");
3161
+ if (args.action === "forget" && args.confirm !== true) throw Error("Forgetting native credentials requires confirm=true");
3162
+ const access2 = { host: dependencies.host, signal, assertCurrent: () => {
3163
+ dependencies.context?.assertInvocationCurrent?.();
3164
+ if (!dependencies.context) return;
3165
+ const context = dependencies.context;
3166
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3167
+ const current = config && resolveWeChatAccount(config);
3168
+ if (!current?.enabled || !current.native || nativeConfigurationKey(current.native) !== configurationKey) throw Error("Native login configuration changed; invoke the tool again");
3169
+ } };
3170
+ guard(access2);
3171
+ if (args.action === "start" || args.action === "wait") {
3172
+ const result2 = args.action === "start" ? await nativeLoginStart(account, { force: args.force, timeoutMs: args.timeoutMs }, access2) : await nativeLoginWait(account, args.timeoutMs, false, access2);
3142
3173
  const content = [{ type: "text", text: result2.message }];
3143
3174
  if (result2.qrDataUrl) content.push({ type: "image", data: result2.qrDataUrl.split(",")[1], mimeType: "image/png" });
3144
- return { content, details: { connected: result2.connected } };
3175
+ return { content, details: { connected: result2.connected, state: result2.state, operationId: result2.operationId } };
3145
3176
  }
3177
+ const native = account.native;
3178
+ const { client } = await (access2.host ?? ensureNativeHost)(native);
3179
+ guard(access2);
3146
3180
  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
3181
  throw new Error("Unknown native login action");
3148
3182
  })();
3183
+ if (args.action !== "status") operations.delete(account.accountId);
3184
+ guard(access2);
3149
3185
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3150
3186
  }
3151
3187
  };
3152
3188
  }
3189
+ function registerNativeLoginTool(api) {
3190
+ api.registerTool({ contextVersion: 2, create: (context) => {
3191
+ const config = context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
3192
+ const account = config && resolveWeChatAccount(config);
3193
+ return account?.enabled && account.native ? [createNativeLoginTool(account, { context })] : [];
3194
+ } }, { names: ["wechat_login"] });
3195
+ }
3153
3196
 
3154
3197
  // src/channel.ts
3155
3198
  async function sendWeChatText(cfg, to, text3) {
@@ -3346,7 +3389,7 @@ var wechatPlugin = {
3346
3389
  loginWithQrWait: async ({ accountId, timeoutMs }) => {
3347
3390
  const account = resolveWeChatAccount(getWeChatRuntime().config.current(), accountId ?? void 0);
3348
3391
  if (!account?.native) return { connected: false, message: "No native daemon configured" };
3349
- return nativeLoginWait(account.accountId, timeoutMs);
3392
+ return nativeLoginWait(account, timeoutMs);
3350
3393
  }
3351
3394
  },
3352
3395
  auth: {
@@ -3372,10 +3415,6 @@ var wechatPlugin = {
3372
3415
  linked: runtime2?.linked ?? false
3373
3416
  })
3374
3417
  },
3375
- agentTools: (({ cfg }) => {
3376
- const account = resolveWeChatAccount(cfg);
3377
- return account?.native ? [createNativeLoginTool(account)] : [];
3378
- }),
3379
3418
  directory: {
3380
3419
  self: async ({ cfg }) => {
3381
3420
  const account = resolveWeChatAccount(cfg);
@@ -3600,7 +3639,7 @@ function string(value, name3, max = 4096) {
3600
3639
  function threadId(value) {
3601
3640
  return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
3602
3641
  }
3603
- function messageResult(message, retrievalTool = "wechat_media") {
3642
+ function messageResult(message, retrievalTool = "wechat_media", quotePermitted = false) {
3604
3643
  if (nativeRecallInfo(message)) return {
3605
3644
  id: message.id,
3606
3645
  threadId: message.threadId,
@@ -3615,8 +3654,15 @@ function messageResult(message, retrievalTool = "wechat_media") {
3615
3654
  const isForward = message.type === 49 && message.appMessageType === 19;
3616
3655
  const isLink = message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link;
3617
3656
  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]`;
3657
+ 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]`;
3658
+ const quote = message.quote && {
3659
+ serverId: message.quote.serverId,
3660
+ type: message.quote.type,
3661
+ state: quotePermitted ? message.quote.state : "not permitted",
3662
+ senderId: message.quote.senderId,
3663
+ appMessageType: message.quote.appMessageType,
3664
+ embeddedImage: quotePermitted ? message.quote.embeddedImage : void 0
3665
+ };
3620
3666
  return {
3621
3667
  id: message.id,
3622
3668
  threadId: message.threadId,
@@ -3625,7 +3671,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
3625
3671
  direction: message.direction,
3626
3672
  type: message.type,
3627
3673
  appMessageType: message.appMessageType,
3628
- quote: message.quote,
3674
+ quote,
3629
3675
  link: isLink ? message.link : void 0,
3630
3676
  createdAt: message.createdAt,
3631
3677
  body: body.slice(0, 4e3),
@@ -3676,7 +3722,7 @@ function createLookupTools(context, dependencies, archive) {
3676
3722
  parameters: { type: "object", additionalProperties: false, properties, required },
3677
3723
  execute: async (_callId, input, signal) => {
3678
3724
  const args = object(input);
3679
- const guard = () => {
3725
+ const guard2 = () => {
3680
3726
  signal?.throwIfAborted();
3681
3727
  context.assertInvocationCurrent?.();
3682
3728
  const cfg2 = currentConfig();
@@ -3684,7 +3730,7 @@ function createLookupTools(context, dependencies, archive) {
3684
3730
  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
3731
  return { cfg: cfg2, account: account2, native: account2.native };
3686
3732
  };
3687
- const { cfg, account, native } = guard();
3733
+ const { cfg, account, native } = guard2();
3688
3734
  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
3735
  const limit = args.limit ?? 20;
3690
3736
  if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
@@ -3701,7 +3747,7 @@ function createLookupTools(context, dependencies, archive) {
3701
3747
  const core = dependencies.core ?? getWeChatRuntime();
3702
3748
  const allowed = async (sender) => {
3703
3749
  if (archive) return true;
3704
- const { cfg: cfg2, account: account2 } = guard();
3750
+ const { cfg: cfg2, account: account2 } = guard2();
3705
3751
  const result2 = await resolveWeChatIngress({
3706
3752
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
3707
3753
  cfg: cfg2,
@@ -3715,10 +3761,15 @@ function createLookupTools(context, dependencies, archive) {
3715
3761
  return result2.ingress.admission === "dispatch";
3716
3762
  };
3717
3763
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3718
- guard();
3764
+ guard2();
3719
3765
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3720
- guard();
3766
+ guard2();
3721
3767
  const accountId = native.accountId;
3768
+ const visibleMessage = async (message) => messageResult(
3769
+ message,
3770
+ archive ? "wechat_archive_media" : "wechat_media",
3771
+ !message.quote || archive || message.quote.direction === "outgoing" || !!message.quote.senderId && await allowed(message.quote.senderId)
3772
+ );
3722
3773
  let result;
3723
3774
  if (action === "threads") {
3724
3775
  const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
@@ -3731,21 +3782,20 @@ function createLookupTools(context, dependencies, archive) {
3731
3782
  result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
3732
3783
  } else if (action === "message" || action === "media") {
3733
3784
  const message = await client.message(accountId, messageId);
3734
- guard();
3785
+ guard2();
3735
3786
  if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
3736
3787
  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");
3788
+ if (action === "message") result = await visibleMessage(message);
3738
3789
  if (action === "media") {
3739
3790
  const checkRecall = async () => {
3740
- guard();
3791
+ guard2();
3741
3792
  const latest = await client.message(accountId, messageId);
3742
- guard();
3793
+ guard2();
3743
3794
  if (latest.id !== message.id || latest.accountId !== accountId || latest.threadId !== message.threadId || latest.serverId !== message.serverId) throw Error("Native media message identity changed");
3744
3795
  if (nativeRecallInfo(latest)) throw new NativeApiError(410, "message_recalled", false);
3745
3796
  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);
3797
+ if (latest.quote?.state === "recalled") throw new NativeApiError(410, "message_recalled", false);
3798
+ if (!latest.quote || JSON.stringify(latest.quote) !== JSON.stringify(message.quote)) throw new NativeApiError(409, "media_source_changed", false);
3749
3799
  }
3750
3800
  };
3751
3801
  let prepared;
@@ -3760,7 +3810,7 @@ function createLookupTools(context, dependencies, archive) {
3760
3810
  download: args.download !== false,
3761
3811
  waitMs: 2e3,
3762
3812
  signal,
3763
- assertCurrent: guard,
3813
+ assertCurrent: guard2,
3764
3814
  allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
3765
3815
  beforeMediaAccess: async () => {
3766
3816
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
@@ -3768,7 +3818,7 @@ function createLookupTools(context, dependencies, archive) {
3768
3818
  }
3769
3819
  });
3770
3820
  } catch (error) {
3771
- guard();
3821
+ guard2();
3772
3822
  if (!(error instanceof NativeApiError)) throw error;
3773
3823
  const details2 = {
3774
3824
  messageId: message.id,
@@ -3779,7 +3829,7 @@ function createLookupTools(context, dependencies, archive) {
3779
3829
  };
3780
3830
  return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
3781
3831
  }
3782
- guard();
3832
+ guard2();
3783
3833
  if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
3784
3834
  const details = {
3785
3835
  messageId: message.id,
@@ -3796,7 +3846,7 @@ function createLookupTools(context, dependencies, archive) {
3796
3846
  };
3797
3847
  const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
3798
3848
  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();
3849
+ guard2();
3800
3850
  try {
3801
3851
  await checkRecall();
3802
3852
  } catch (error) {
@@ -3813,7 +3863,7 @@ function createLookupTools(context, dependencies, archive) {
3813
3863
  for (const message of page.items) {
3814
3864
  if (isNativeTombstone(message)) continue;
3815
3865
  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"));
3866
+ if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(await visibleMessage(message));
3817
3867
  }
3818
3868
  result = {
3819
3869
  ...page,
@@ -3823,7 +3873,7 @@ function createLookupTools(context, dependencies, archive) {
3823
3873
  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
3874
  };
3825
3875
  }
3826
- guard();
3876
+ guard2();
3827
3877
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3828
3878
  }
3829
3879
  });
@@ -3838,13 +3888,13 @@ function createLookupTools(context, dependencies, archive) {
3838
3888
  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
3889
  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
3890
  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"])
3891
+ 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
3892
  ];
3843
3893
  return [
3844
3894
  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
3895
  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
3896
  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"])
3897
+ 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
3898
  ];
3849
3899
  }
3850
3900
  function registerNativeHistoryTools(api) {
@@ -3910,7 +3960,7 @@ function createNativeShareTools(context, dependencies = {}) {
3910
3960
  ...args.description !== void 0 ? { description: args.description === "" ? "" : text(args.description, "description", 16384, true) } : {},
3911
3961
  ...args.thumbnailUrl !== void 0 ? { thumbnailUrl: url(args.thumbnailUrl, "thumbnail URL") } : {}
3912
3962
  };
3913
- const guard = () => {
3963
+ const guard2 = () => {
3914
3964
  signal?.throwIfAborted();
3915
3965
  context.assertInvocationCurrent?.();
3916
3966
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -3919,7 +3969,7 @@ function createNativeShareTools(context, dependencies = {}) {
3919
3969
  };
3920
3970
  const core = dependencies.core ?? getWeChatRuntime();
3921
3971
  const authorize = async () => {
3922
- const { cfg, account } = guard();
3972
+ const { cfg, account } = guard2();
3923
3973
  const result = await resolveWeChatIngress({
3924
3974
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress3,
3925
3975
  cfg,
@@ -3930,11 +3980,11 @@ function createNativeShareTools(context, dependencies = {}) {
3930
3980
  senderId: requester,
3931
3981
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
3932
3982
  });
3933
- guard();
3983
+ guard2();
3934
3984
  if (result.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
3935
3985
  };
3936
3986
  await authorize();
3937
- const { native } = guard();
3987
+ const { native } = guard2();
3938
3988
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
3939
3989
  await authorize();
3940
3990
  let intent;
@@ -4012,7 +4062,7 @@ function createNativeRecallTools(context, dependencies = {}) {
4012
4062
  const messageId = text2(input.messageId, "message ID");
4013
4063
  const serverId = text2(input.serverId, "server ID");
4014
4064
  if (!/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n) throw Error("Invalid exact server message ID");
4015
- const guard = () => {
4065
+ const guard2 = () => {
4016
4066
  signal?.throwIfAborted();
4017
4067
  context.assertInvocationCurrent?.();
4018
4068
  const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
@@ -4020,7 +4070,7 @@ function createNativeRecallTools(context, dependencies = {}) {
4020
4070
  return { cfg, account, native: account.native };
4021
4071
  };
4022
4072
  const allowed = async () => {
4023
- const { cfg, account } = guard();
4073
+ const { cfg, account } = guard2();
4024
4074
  const core = dependencies.core ?? getWeChatRuntime();
4025
4075
  const ingress = await resolveWeChatIngress({
4026
4076
  resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress4,
@@ -4032,20 +4082,20 @@ function createNativeRecallTools(context, dependencies = {}) {
4032
4082
  senderId: requester,
4033
4083
  readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
4034
4084
  });
4035
- guard();
4085
+ guard2();
4036
4086
  if (ingress.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
4037
4087
  };
4038
4088
  await allowed();
4039
- const { native } = guard();
4089
+ const { native } = guard2();
4040
4090
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4041
- guard();
4091
+ guard2();
4042
4092
  const message = await client.message(accountId, messageId);
4043
- guard();
4093
+ guard2();
4044
4094
  const retainedRecall = !!nativeRecallInfo(message);
4045
4095
  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
4096
  if (message.serverId !== serverId) throw Error("The exact server identity does not match this receipt; the local message identity may have been reused");
4047
4097
  await allowed();
4048
- guard();
4098
+ guard2();
4049
4099
  const key = nativeRecallKey(accountId, conversation, messageId, serverId);
4050
4100
  let operation;
4051
4101
  let operationId;
@@ -4075,13 +4125,13 @@ function createNativeRecallTools(context, dependencies = {}) {
4075
4125
  const deadline = Date.now() + Math.max(0, Math.min(dependencies.waitMs ?? 2e3, 1e4));
4076
4126
  while (["queued", "running", "submitting"].includes(operation.state) && Date.now() < deadline) {
4077
4127
  await new Promise((resolve) => setTimeout(resolve, Math.min(250, deadline - Date.now())));
4078
- guard();
4128
+ guard2();
4079
4129
  const remaining = Math.max(1, deadline - Date.now());
4080
4130
  const next = await client.operation(accountId, operation.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
4081
4131
  checkOperation(next);
4082
4132
  operation = next;
4083
4133
  }
4084
- guard();
4134
+ guard2();
4085
4135
  } catch (error) {
4086
4136
  const reason = error instanceof NativeApiError ? error.code : "recall_result_unavailable";
4087
4137
  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 +4177,7 @@ function createNativeGroupTools(context, dependencies = {}) {
4127
4177
  const args = input;
4128
4178
  const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/u.test(value);
4129
4179
  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 = () => {
4180
+ const guard2 = () => {
4131
4181
  signal?.throwIfAborted();
4132
4182
  if (context.senderIsOwner !== true) throw Error("OpenClaw owner authorization is required");
4133
4183
  context.assertInvocationCurrent();
@@ -4135,14 +4185,14 @@ function createNativeGroupTools(context, dependencies = {}) {
4135
4185
  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
4186
  return account.native;
4137
4187
  };
4138
- const native = guard();
4188
+ const native = guard2();
4139
4189
  const { client } = await (dependencies.host ?? ensureNativeHost)(native);
4140
- guard();
4190
+ guard2();
4141
4191
  const group = await client.group(accountId, args.groupId, signal);
4142
- guard();
4192
+ guard2();
4143
4193
  if (group.id !== args.groupId || group.roomId !== args.groupId) throw Error("Group identity is invalid");
4144
4194
  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();
4195
+ guard2();
4146
4196
  const details = { accountId, group, members, source: "cached", membershipAbsence: "unconfirmed" };
4147
4197
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
4148
4198
  }
@@ -4166,6 +4216,7 @@ var plugin = {
4166
4216
  registerNativeShareTools(api);
4167
4217
  registerNativeRecallTools(api);
4168
4218
  registerNativeGroupTools(api);
4219
+ registerNativeLoginTool(api);
4169
4220
  const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
4170
4221
  };
4171
4222
  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.14",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  "esbuild": "^0.25.0",
12
12
  "openclaw": "2026.10.2",
13
13
  "typescript": "^5.4.5",
14
- "@agent-wechat/shared": "1.0.0-beta.2"
14
+ "@agent-wechat/shared": "1.0.0-beta.3"
15
15
  },
16
16
  "openclaw": {
17
17
  "extensions": [