@agent-wechat/agent-wechat 1.0.0-beta.5 → 1.0.0-beta.7
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 +81 -6
- package/dist/index.js +604 -62
- package/openclaw.plugin.json +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Configure plugin defaults under `plugins.entries.agent-wechat.config` (or the sa
|
|
|
16
16
|
"endpoint": "http://127.0.0.1:6174",
|
|
17
17
|
"token": { "source": "env", "provider": "default", "id": "AGENT_WECHAT_TOKEN" },
|
|
18
18
|
"consumerStateDir": "/private/openclaw-native-consumer",
|
|
19
|
-
"readPolicy": "
|
|
19
|
+
"readPolicy": "on_inbound"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
```
|
|
@@ -50,15 +50,90 @@ Keep `dataDir` and `consumerStateDir` on separate explicit persistent mounts. Th
|
|
|
50
50
|
|
|
51
51
|
The adapter consumes account-scoped SSE using bearer headers and decimal-string journal cursors. It persists custody before acknowledging a cursor; acknowledgement is independent of unread state. Only explicitly live, incoming, committed resources can enter the model. Supported images, short voice, video, files and eligible stickers pass through bounded media retrieval and hash verification into OpenClaw-managed storage. Pending media retains custody without model dispatch or a read receipt. Expired/unsupported attachments retain an explicit status; raw XML never becomes an attachment. Catch-up/import/raw pending sync batches, unknown origin and outgoing messages do not become automatic model inputs. Group admission uses structured exact self-mention facts; unknown metadata or displayed `@names` never satisfy a mention gate. Contacts and groups use their separate catalogs.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Every ready attachment supplies its original OpenClaw-managed path in the message body and `Attachments` metadata, including images. File references wrap paths in angle brackets, such as `[Image file: <path>]` or `[Local file: <path>]`, so OpenClaw does not automatically load an original or a file with an image extension when scanning prompt text. A separately prepared image preview may occupy `MediaPath` while the original file remains accessible by its own path. Files, voice and video retain explicit local references; an available path does not claim that the model analyzed the file.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
In groups requiring a mention, sender-admitted unmentioned messages are retained as context without retrieval, transcription, a model turn or a read notification. The next admitted non-command message includes that context after checking the current sender policy again. `messages.groupChat.historyLimit` controls the per-group window (default 50, maximum 200; zero disables it). The consumer store retains at most 1,000 buffered group messages overall. Context survives a Gateway restart. At dispatch, the selected context is consumed atomically with the durable turn record, so an uncertain turn is not offered again automatically. Original mention facts remain unchanged; history is recorded separately from the triggering message.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Group catch-up retrieves media with at most three workers, keeps every verified original path and selects only the latest ready image's prepared representation for vision. Pending or unavailable historical attachments remain explicit statuses and do not block the current mention. The triggering attachment, when still pending, continues to hold its own dispatch. Voice transcription runs only after admission and activation. Commands do not drain buffered context.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
By default, `readPolicy: "on_inbound"` marks a whole thread read after its incoming message has been durably stored, passed sender/group/mention checks and completed normal OpenClaw inbound handling. Built-in commands also qualify when no model is called. This policy does not inspect model inputs or require `hooks.allowConversationAccess`. Ignored messages, pending media and failed handling do not initiate a read. Read means handled by the integration; it does not claim that a model analyzed an attachment.
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
A whole-thread read also clears earlier unread messages in that thread, including messages the model did not receive. The daemon freezes the latest local message boundary, checks the expected version, updates local unread state and submits one native read-status notification. Server acceptance alone does not prove that a phone badge changed. A lost notification response remains `needs_review` and is never automatically resubmitted. Pending reads survive a Gateway restart and retry independently of model execution or new SSE traffic. `readPolicy: "never"` disables both local and remote marking, including previously queued reads. Crashes during an unfinished dispatch retain `needs_review`; the plugin does not automatically duplicate model turns or uncertain outgoing sends.
|
|
62
|
+
|
|
63
|
+
The optional `readPolicy: "on_model_dispatch"` instead requires a confirmed model handoff. OpenClaw **2026.10.2** lacks `onPreparedAgentRunStart`, so its released public hooks provide a bounded compatibility path. Before dispatch, the adapter persists an immutable manifest binding the exact new message ID/version/content hash, policy hash, source and supplied asset hashes, and context roles. Text requires the exact current input and host-created message/account/session facts plus a matching `model_call_started`. Media additionally waits for the same run's `agent_end` snapshot and source-turn identity. Image bytes/MIME/length must match the frozen supplied representation; file/video/voice references must appear in the exact current body. Image count, model selection and reply delivery alone cannot confirm a model handoff. Unknown, omitted, rewritten, merged or ambiguous inputs remain unconfirmed. Host/provider history formats can prevent confirmation even when a reply was delivered; use `on_inbound` for ordinary bot read behavior.
|
|
64
|
+
|
|
65
|
+
The model-handoff compatibility path requires the operator's explicit host permission, `plugins.entries.agent-wechat.hooks.allowConversationAccess: true`. It observes only run IDs issued by this adapter and never enables that permission itself. Without the grant, or on an unsupported host, the explicit `on_model_dispatch` policy leaves messages unread and status reports `handoffUnconfirmed`. The older `onAgentRunStart` also reports direct tools and early lifecycle phases and is never used as proof. Access and mention gates still precede model dispatch; a rejected message does not itself trigger a read.
|
|
66
|
+
|
|
67
|
+
Images exceeding the host's bounded representation, or formats requiring conversion, receive a separately stored JPEG still preview using the public SDK image processor. The original stays intact; both hashes are recorded and the model input labels the preview. Prepared images fit within 5 MiB and at most 1200 pixels per side (or a smaller configured limit). Files and videos can be supplied as available local references; this does not claim their contents were analyzed. Voice retains its verified file and includes configured transcription output or an explicit unavailable status. A placeholder does not confirm a model handoff under `on_model_dispatch`; default `on_inbound` can mark a thread after successfully handling an explicit expired/unsupported status.
|
|
68
|
+
|
|
69
|
+
## Stored-history tools
|
|
70
|
+
|
|
71
|
+
The agent can look beyond the recent inbound buffer using the selected native account's local profile:
|
|
72
|
+
|
|
73
|
+
| Tool | Inputs (`?` means optional) | Output |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `wechat_history` | `{ limit?, cursor? }` | Stored messages, newest first, with `nextCursor`, `sourceGeneration` and `coverage` |
|
|
76
|
+
| `wechat_search` | `{ query, limit?, cursor? }` | Same page shape, filtered by case-insensitive substring |
|
|
77
|
+
| `wechat_message` | `{ messageId }` | One stored message |
|
|
78
|
+
| `wechat_media` | `{ messageId, download?, quality? }` | Media status, optional verified file path and image content |
|
|
79
|
+
|
|
80
|
+
A stored message includes `id`, `threadId`, nullable `serverId`, `senderId`, `direction` and `createdAt`, numeric `type`, bounded `body`, and `bodyTruncated`. Its optional `attachment` descriptor contains `kind`, `messageId` and `retrievalTool: "wechat_media"`. Message IDs and server IDs remain strings; use the opaque `id` for exact lookup and media retrieval.
|
|
81
|
+
|
|
82
|
+
A media result includes `messageId`, `threadId`, `status` and `retryable`, with optional `attachment`, actual `variant`, `jobId`, `reason` and display `body`. A ready attachment has `kind`, `filename`, verified local `path` and `mime`; image quality may be `full`, `standard` or `thumbnail`. Unavailable results never claim a local path. OpenClaw wraps structured outputs in `details` and text `content`; a ready image additionally supplies an image content block.
|
|
83
|
+
|
|
84
|
+
For example, the agent can call `wechat_search` with `{"query":"project notes"}` in the current conversation. These four default tools are restricted to the current WeChat account and thread derived from trusted OpenClaw context. Owners and agents with archive access have the same restriction when invoking a default tool. Default tools accept no thread/account selector, and the plugin rejects messages or assets from another conversation or account. Requester and historical sender policy are rechecked. Missing or conflicting trusted conversation context hides the default tools; webchat and other channels cannot use them.
|
|
85
|
+
|
|
86
|
+
History/search return opaque string message IDs, a bounded text preview, coverage metadata and `nextCursor`. Pass the cursor back with the same tool, query and thread to continue; the page limit is 1–50 (default 20). Search queries are limited to 256 UTF-8 bytes. Long message bodies are labeled as truncated. A sender-filtered page may be empty and still have a continuation. Search covers locally stored history and does not fetch missing remote history or maintain an official FTS index. Media messages receive a type/status description; these tools do not return raw transport XML or download attachments. History, search and exact-message lookup are read-only. Their attachment descriptions name `wechat_media` and the exact message ID without automatically retrieving media. None of these tools mark a conversation read, send messages or modify the inbound consumer cursor. Lookup requires the selected account's API `read` scope and remains subject to normal OpenClaw tool policy. With OpenClaw's compact tool catalog, the agent discovers history/search/message lookup through `tool_search` and invokes the returned tool with `tool_call`. The media tool stays directly callable as `wechat_media`: the pinned host's generic wrapper serializes nested image blocks as JSON text, while direct invocation preserves image content for vision.
|
|
87
|
+
|
|
88
|
+
`wechat_media` accepts `{"messageId":"<returned opaque ID>"}`. It verifies message/thread/sender admission before accessing assets, uses cached bytes first, checks identity, hash and size, and saves verified bytes to OpenClaw's managed media store. Files, videos and voice return local paths; images also return a bounded prepared preview as image content for vision. This retrieval does not itself transcribe voice or analyze files/videos. Cached bytes remain usable after CDN expiry.
|
|
89
|
+
|
|
90
|
+
Missing media requests a durable daemon transfer by default, with a two-second bounded wait. A pending result includes its `jobId` when known and no claimed local path; call the same tool with the same message ID later to check. The daemon owns and deduplicates the transfer, so cancelling a tool invocation does not cancel submitted work. Use `download: false` for cached media only. `quality` defaults to `best` (best cached image, normal video or decoded voice); explicit `original`, `standard`, `thumbnail`, `normal` and `encoded` variants are strict and may be unavailable. Results report the actual variant and explicit pending, expired, unsupported, unavailable or too-large status. Never automatically retry `retryable: false` or confirmed expiry. The media tool requires account-scoped API `read` and `media` permission, including for download requests. Raw XML, signed transport URLs and keys are never returned.
|
|
91
|
+
|
|
92
|
+
`node scripts/native-history-model-smoke.mjs` exercises the pinned Gateway with local account/media/model fixtures: durable group context, original file paths, one preview with a verified hash, a PNG sent as a file remaining a reference, direct image delivery, actual tool discovery, two paginated search calls, rejection of another thread's attachment before media access, one requested image download whose exact image bytes reach vision, and a cached file path reaching the provider. CI runs this check on both Linux architectures.
|
|
93
|
+
|
|
94
|
+
When asset metadata identifies an existing download job, retrieval polls that scoped job instead of submitting another transfer. A terminal job's reason and retryability are preserved, including in cache-only mode; this tool does not automatically retry failed jobs. A service rejection alone is not classified as expiry.
|
|
95
|
+
|
|
96
|
+
### Privileged archive tools
|
|
97
|
+
|
|
98
|
+
Five optional tools read across every thread in the configured native WeChat account:
|
|
99
|
+
|
|
100
|
+
| Tool | Inputs (`?` means optional) | Output |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `wechat_archive_threads` | `{ query?, limit?, cursor? }` | Thread page: `id`, `peerId`, `kind`, `displayName`, `unreadCount`, plus pagination and coverage |
|
|
103
|
+
| `wechat_archive_search` | `{ query, threadId?, limit?, cursor? }` | Message page across all threads; optional `threadId` narrows it |
|
|
104
|
+
| `wechat_archive_history` | `{ threadId, limit?, cursor? }` | Message page for any selected thread, newest first |
|
|
105
|
+
| `wechat_archive_message` | `{ messageId }` | One message from any thread |
|
|
106
|
+
| `wechat_archive_media` | `{ messageId, download?, quality? }` | Verified attachment status/path and image content from any thread |
|
|
107
|
+
|
|
108
|
+
Message/page/media output shapes, bounds and download behavior match the default tools above. Archive attachment descriptors name `wechat_archive_media` as their retrieval tool. Thread queries match locally stored IDs and display names. Search uses the daemon's account-wide `GET /v1/accounts/{accountId}/messages?query=...` endpoint; its opaque cursor binds the account, query and thread selection and retains membership/order across local edits, deletions and new arrivals. Search reads the local profile with bounded snapshot capacity; it does not backfill remote history. An older daemon without that endpoint must be upgraded for account-wide search.
|
|
109
|
+
|
|
110
|
+
Configure access in OpenClaw's `~/.openclaw/openclaw.json`, merging into your existing named agent entries:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"agents": {
|
|
115
|
+
"ownership": "explicit",
|
|
116
|
+
"entries": {
|
|
117
|
+
"main": {
|
|
118
|
+
"tools": { "alsoAllow": ["wechat_archive_*"] }
|
|
119
|
+
},
|
|
120
|
+
"group-bot": {
|
|
121
|
+
"tools": { "deny": ["wechat_archive_*"] }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
OpenClaw enforces these policies for tool execution and catalog discovery. The `main` name has no implicit privilege. Use individual tool names to grant a subset. If an agent already has `tools.allow`, add the archive names to that list instead of combining `allow` and `alsoAllow`. A global deny still wins over a per-agent allow. Optional tools are absent without a selecting policy, but `profile: "full"`, `*`, `group:plugins`, or an allow for the entire `agent-wechat` plugin also selects them; add an explicit per-agent deny for agents that should retain only current-thread access.
|
|
129
|
+
|
|
130
|
+
There is no plugin permission list, owner exception or archive sender/thread filter. A host-authorized archive invocation can read the configured account from any channel, including a group invocation. Use OpenClaw's sender policies or route group traffic to an agent without archive tools when those runs should have narrower access. Archive tools stay bound to the configured account and verify message/asset identities, hashes and sizes. They never accept arbitrary file paths or another account selector. Normal account/token scope checks and invocation cancellation fences still apply.
|
|
131
|
+
|
|
132
|
+
History/search/message/thread reads never send messages, mark threads read or change the inbound cursor. Archive media can submit the same bounded, deduplicated download jobs as `wechat_media`; it stays directly callable so actual image blocks reach vision.
|
|
133
|
+
|
|
134
|
+
Run `node scripts/native-archive-tools-smoke.mjs` to check the pinned Gateway with three separately routed agents: no optional-tool grant, a full profile with an explicit deny, and an archive grant. It checks discovery and guessed-name invocation denial, cross-thread search pagination, other-sender history/media, unchanged default-tool scope, verified image bytes in provider input and a cached file path. All messages, account data and model responses in this test are synthetic. CI runs it on Linux x64 and ARM64.
|
|
135
|
+
|
|
136
|
+
Run `node scripts/native-model-smoke.mjs` and `node scripts/native-media-model-smoke.mjs` after installing workspace dependencies. They use the actual pinned Gateway, a local model and synthetic daemon/files. Checks cover default inbound reads without conversation permission, commands without a model call, current provider input, image hashes and prepared previews, reference/transcription delivery, whole-thread read requests, lost-response/reopen recovery, duplicate prevention, omitted images on a text-only model, explicit model-handoff permission, never-read policy and group routing behavior. Transcription uses the real SDK with an explicitly configured local deterministic CLI; it does not test speech recognition accuracy. Concurrent text inputs were serialized into distinct model turns; merged-input refusal has unit coverage for the explicit model policy. These checks establish local runtime behavior, not external provider acceptance, semantic analysis or consumption.
|
|
62
137
|
|
|
63
138
|
Native text/media sends retain daemon intent/job identity and caller idempotency. Text waits for accepted submission; media jobs preserve partial and uncertain outcomes rather than resending. Ordinary audio remains a file; only explicit `audioAsVoice` requests independently encoded voice. Sender/group/mention gates run before retrieval/transcription. Verified voice may use the configured OpenClaw transcription runtime after admission. Native QR login uses a durable daemon operation and authenticated PNG retrieval. Logout commits local inhibition before at most one authenticated native logout, reporting the remote outcome separately; saved-device revocation remains unverified. Explicitly confirmed forgetting removes local auth while retaining history; disconnect retains reusable trust.
|
|
64
139
|
|
package/dist/index.js
CHANGED
|
@@ -322,8 +322,8 @@ var NativeWeChatClient = class {
|
|
|
322
322
|
groups(accountId, options) {
|
|
323
323
|
return this.request(`v1/accounts/${id(accountId)}/groups${listQuery(options)}`);
|
|
324
324
|
}
|
|
325
|
-
thread(accountId,
|
|
326
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
325
|
+
thread(accountId, threadId2) {
|
|
326
|
+
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}`);
|
|
327
327
|
}
|
|
328
328
|
contact(accountId, username) {
|
|
329
329
|
return this.request(`v1/accounts/${id(accountId)}/contacts/${id(username)}`);
|
|
@@ -361,12 +361,12 @@ var NativeWeChatClient = class {
|
|
|
361
361
|
syncGroups(accountId) {
|
|
362
362
|
return this.request(`v1/accounts/${id(accountId)}/group-sync`, { method: "POST", body: "{}" });
|
|
363
363
|
}
|
|
364
|
-
async messages(accountId,
|
|
365
|
-
const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
364
|
+
async messages(accountId, threadId2, options) {
|
|
365
|
+
const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/messages${listQuery(options)}`);
|
|
366
366
|
for (const msg of result.items) {
|
|
367
367
|
if (isNativeTombstone(msg))
|
|
368
368
|
continue;
|
|
369
|
-
if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !==
|
|
369
|
+
if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId2 || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
|
|
370
370
|
throw new Error("Invalid native message identity");
|
|
371
371
|
}
|
|
372
372
|
return result;
|
|
@@ -377,10 +377,28 @@ var NativeWeChatClient = class {
|
|
|
377
377
|
throw new Error("Invalid native message identity");
|
|
378
378
|
return message;
|
|
379
379
|
}
|
|
380
|
-
|
|
380
|
+
/** Resolve only the stored original in this message's own conversation. */
|
|
381
|
+
async quotedMessage(accountId, messageId) {
|
|
382
|
+
const quote = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/quote`);
|
|
383
|
+
if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
|
|
384
|
+
throw new Error("Invalid native quote identity");
|
|
385
|
+
return quote;
|
|
386
|
+
}
|
|
387
|
+
/** Account-wide local search, with the daemon's query-bound opaque cursor. */
|
|
388
|
+
async searchMessages(accountId, options) {
|
|
389
|
+
const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
|
|
390
|
+
for (const message of result.items) {
|
|
391
|
+
if (isNativeTombstone(message))
|
|
392
|
+
continue;
|
|
393
|
+
if (typeof message.id !== "string" || message.accountId !== accountId || typeof message.threadId !== "string" || !message.threadId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
|
|
394
|
+
throw new Error("Invalid native archive message identity");
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
sendText(accountId, threadId2, text, idempotencyKey) {
|
|
381
399
|
if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
|
|
382
400
|
throw new Error("An idempotency key is required");
|
|
383
|
-
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId, text }) });
|
|
401
|
+
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text }) });
|
|
384
402
|
}
|
|
385
403
|
send(accountId, sendId, signal) {
|
|
386
404
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
|
|
@@ -440,10 +458,10 @@ var NativeWeChatClient = class {
|
|
|
440
458
|
}
|
|
441
459
|
return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), status: response.status };
|
|
442
460
|
}
|
|
443
|
-
markRead(accountId,
|
|
461
|
+
markRead(accountId, threadId2, expectedVersion) {
|
|
444
462
|
if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
|
|
445
463
|
throw new Error("Read version is required");
|
|
446
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
464
|
+
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
|
|
447
465
|
}
|
|
448
466
|
events(accountId, after = "0", limit = 100) {
|
|
449
467
|
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
@@ -1024,17 +1042,18 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
|
|
|
1024
1042
|
if (attachment.kind === "file") {
|
|
1025
1043
|
const prefix = originalBody.trim() === name ? "" : originalBody;
|
|
1026
1044
|
return appendReference(prefix, `[File: ${name}]
|
|
1027
|
-
[Local file:
|
|
1045
|
+
[Local file: <${attachment.path}>]`);
|
|
1028
1046
|
}
|
|
1029
1047
|
if (exposeNonVisualPath) {
|
|
1030
1048
|
if (attachment.kind === "audio") {
|
|
1031
|
-
return appendReference(originalBody || "<media:audio>", `[Voice message file:
|
|
1049
|
+
return appendReference(originalBody || "<media:audio>", `[Voice message file: <${attachment.path}>]`);
|
|
1032
1050
|
}
|
|
1033
1051
|
if (attachment.kind === "video") {
|
|
1034
|
-
return appendReference(originalBody || "<media:video>", `[Video file:
|
|
1052
|
+
return appendReference(originalBody || "<media:video>", `[Video file: <${attachment.path}>]`);
|
|
1035
1053
|
}
|
|
1036
1054
|
}
|
|
1037
|
-
|
|
1055
|
+
let body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
|
|
1056
|
+
if (attachment.kind === "image") body = appendReference(body, `[Image file: <${attachment.path}>]`);
|
|
1038
1057
|
if (attachment.kind === "image" && attachment.quality === "thumbnail") {
|
|
1039
1058
|
return appendReference(body, "[Image preview only: thumbnail quality]");
|
|
1040
1059
|
}
|
|
@@ -1046,26 +1065,30 @@ function kind(type) {
|
|
|
1046
1065
|
return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
|
|
1047
1066
|
}
|
|
1048
1067
|
var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
|
|
1049
|
-
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3) {
|
|
1068
|
+
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
|
|
1050
1069
|
const deadline = Date.now() + timeoutMs;
|
|
1051
1070
|
let job = initial;
|
|
1052
1071
|
let delay = 250;
|
|
1053
1072
|
while (!terminalJobs.has(job.state) && Date.now() < deadline) {
|
|
1073
|
+
signal?.throwIfAborted();
|
|
1074
|
+
assertCurrent?.();
|
|
1054
1075
|
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
|
|
1055
1076
|
const remaining = deadline - Date.now();
|
|
1056
1077
|
if (remaining <= 0) break;
|
|
1057
|
-
|
|
1078
|
+
signal?.throwIfAborted();
|
|
1079
|
+
assertCurrent?.();
|
|
1080
|
+
job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
|
|
1058
1081
|
delay = Math.min(delay * 2, 1e3);
|
|
1059
1082
|
}
|
|
1060
1083
|
return job;
|
|
1061
1084
|
}
|
|
1062
|
-
function select(assets, type) {
|
|
1085
|
+
function select(assets, type, quality = "best") {
|
|
1063
1086
|
const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
|
|
1064
1087
|
const rank = (asset) => {
|
|
1065
1088
|
const index = preferred.indexOf(asset.actualVariant ?? asset.variant);
|
|
1066
1089
|
return index < 0 ? preferred.length : index;
|
|
1067
1090
|
};
|
|
1068
|
-
return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (type !== 34 || (asset.actualVariant ?? asset.variant) === "normal"));
|
|
1091
|
+
return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (quality === "best" ? type !== 34 || (asset.actualVariant ?? asset.variant) === "normal" : (asset.actualVariant ?? asset.variant) === quality));
|
|
1069
1092
|
}
|
|
1070
1093
|
async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
1071
1094
|
const configured = cfg?.agents?.defaults?.imageMaxDimensionPx;
|
|
@@ -1079,45 +1102,177 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
|
1079
1102
|
}
|
|
1080
1103
|
throw Error("Native model image representation exceeds its bound");
|
|
1081
1104
|
}
|
|
1105
|
+
async function prepareNativeQuote(params) {
|
|
1106
|
+
const { client, account, message } = params;
|
|
1107
|
+
const guard = () => {
|
|
1108
|
+
params.signal?.throwIfAborted();
|
|
1109
|
+
params.assertCurrent?.();
|
|
1110
|
+
};
|
|
1111
|
+
guard();
|
|
1112
|
+
const comment = (message.body ?? "").slice(0, 16384);
|
|
1113
|
+
const unavailable = (state) => ({ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state}; original lookup is confined to this chat]`, pending: false, retryable: state === "unavailable", quote: { state } });
|
|
1114
|
+
if (!message.quote) return unavailable("invalid");
|
|
1115
|
+
await params.beforeMediaAccess?.();
|
|
1116
|
+
guard();
|
|
1117
|
+
let resolution;
|
|
1118
|
+
try {
|
|
1119
|
+
resolution = await client.quotedMessage(account.native.accountId, message.id);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
guard();
|
|
1122
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
1123
|
+
return unavailable("unavailable");
|
|
1124
|
+
}
|
|
1125
|
+
guard();
|
|
1126
|
+
if (resolution.accountId !== message.accountId || resolution.messageId !== message.id || resolution.threadId !== message.threadId) throw Error("Native quote identity does not match its message");
|
|
1127
|
+
if (resolution.state === "embedded") {
|
|
1128
|
+
if (resolution.message != null || (message.quote.type === 1 ? typeof resolution.embeddedText !== "string" || resolution.embeddedText.length > 16384 : message.quote.type !== 3 || resolution.embeddedImage !== true)) throw Error("Invalid embedded quote resolution");
|
|
1129
|
+
const author = { senderId: resolution.senderId ?? null, direction: null };
|
|
1130
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) return unavailable("not permitted");
|
|
1131
|
+
guard();
|
|
1132
|
+
const quote2 = { state: "embedded", senderId: resolution.senderId ?? void 0, type: message.quote.type };
|
|
1133
|
+
const prefix2 = `${comment}${comment ? "\n\n" : ""}[Quoted ${message.quote.type === 3 ? "image" : "text"} from ${resolution.senderId ?? "unknown sender"}; embedded in quote, original not stored]
|
|
1134
|
+
`;
|
|
1135
|
+
if (message.quote.type === 1) return { body: `${prefix2}${resolution.embeddedText}
|
|
1136
|
+
[/Quoted message]`, pending: false, quote: quote2 };
|
|
1137
|
+
const beforeMediaAccess2 = async () => {
|
|
1138
|
+
await params.beforeMediaAccess?.();
|
|
1139
|
+
guard();
|
|
1140
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
|
|
1141
|
+
guard();
|
|
1142
|
+
};
|
|
1143
|
+
const prepared2 = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0 }, beforeMediaAccess: beforeMediaAccess2 });
|
|
1144
|
+
return {
|
|
1145
|
+
...prepared2,
|
|
1146
|
+
body: `${prefix2}${prepared2.body}
|
|
1147
|
+
[/Quoted message]`,
|
|
1148
|
+
pending: false,
|
|
1149
|
+
quote: quote2,
|
|
1150
|
+
...prepared2.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (resolution.state !== "resolved") {
|
|
1154
|
+
if (!["missing", "invalid", "unsupported", "ambiguous"].includes(resolution.state) || resolution.message != null) throw Error("Invalid native quote resolution");
|
|
1155
|
+
return unavailable(resolution.state);
|
|
1156
|
+
}
|
|
1157
|
+
const original = resolution.message;
|
|
1158
|
+
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");
|
|
1159
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) return unavailable("not permitted");
|
|
1160
|
+
guard();
|
|
1161
|
+
const quote = { state: "resolved", messageId: original.id, senderId: original.senderId ?? void 0, type: original.type };
|
|
1162
|
+
const prefix = `${comment}${comment ? "\n\n" : ""}[Quoted ${original.type === 3 ? "image" : "text"} from ${original.senderId ?? "unknown sender"}; message ${original.id}]
|
|
1163
|
+
`;
|
|
1164
|
+
if (original.type === 1) return { body: `${prefix}${(original.body ?? "").slice(0, 16384)}
|
|
1165
|
+
[/Quoted message]`, pending: false, quote };
|
|
1166
|
+
const beforeMediaAccess = async () => {
|
|
1167
|
+
await params.beforeMediaAccess?.();
|
|
1168
|
+
guard();
|
|
1169
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
|
|
1170
|
+
guard();
|
|
1171
|
+
};
|
|
1172
|
+
const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
|
|
1173
|
+
return {
|
|
1174
|
+
...prepared,
|
|
1175
|
+
body: `${prefix}${prepared.body}
|
|
1176
|
+
[/Quoted message]`,
|
|
1177
|
+
pending: false,
|
|
1178
|
+
quote,
|
|
1179
|
+
...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {},
|
|
1180
|
+
suppliedAssets: prepared.suppliedAssets?.map((asset) => ({ ...asset, sourceMessageId: original.id }))
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1082
1183
|
async function prepareNativeAttachment(params) {
|
|
1083
1184
|
const { client, account, message, core } = params;
|
|
1084
1185
|
const attachmentKind = kind(message.type);
|
|
1186
|
+
const guard = () => {
|
|
1187
|
+
params.signal?.throwIfAborted();
|
|
1188
|
+
params.assertCurrent?.();
|
|
1189
|
+
};
|
|
1190
|
+
guard();
|
|
1191
|
+
const authorize = async () => {
|
|
1192
|
+
guard();
|
|
1193
|
+
await params.beforeMediaAccess?.();
|
|
1194
|
+
guard();
|
|
1195
|
+
};
|
|
1196
|
+
if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
|
|
1197
|
+
if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
|
|
1198
|
+
if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
|
|
1085
1199
|
if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
|
|
1086
1200
|
const accountId = account.native.accountId;
|
|
1087
1201
|
const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
|
|
1088
1202
|
const scopedAssets = async () => {
|
|
1203
|
+
await authorize();
|
|
1089
1204
|
const items = (await client.mediaAssets(accountId, message.id)).items;
|
|
1205
|
+
guard();
|
|
1090
1206
|
if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
|
|
1091
1207
|
return items;
|
|
1092
1208
|
};
|
|
1209
|
+
const quality = params.quality ?? "best";
|
|
1210
|
+
const transferQuality = quality === "best" && (message.type === 34 || message.type === 43) ? "normal" : quality;
|
|
1093
1211
|
let assets = await scopedAssets();
|
|
1094
|
-
let asset = select(assets, message.type);
|
|
1095
|
-
|
|
1212
|
+
let asset = select(assets, message.type, quality);
|
|
1213
|
+
const relevant = () => assets.filter((value) => transferQuality === "best" || value.variant === transferQuality);
|
|
1214
|
+
const transferCandidate = () => {
|
|
1215
|
+
const order = ["original", "normal", "standard", "thumbnail", "encoded"];
|
|
1216
|
+
return [...relevant()].sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant))[0];
|
|
1217
|
+
};
|
|
1218
|
+
const initialCandidate = transferCandidate();
|
|
1219
|
+
if (!asset && initialCandidate?.byteLength != null && initialCandidate.byteLength > maxBytes) {
|
|
1220
|
+
const attachment2 = { kind: attachmentKind, filename: basename(initialCandidate.filename ?? "attachment"), status: "too_large" };
|
|
1221
|
+
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false, retryable: false };
|
|
1222
|
+
}
|
|
1223
|
+
let job;
|
|
1224
|
+
let transferError;
|
|
1225
|
+
if (!asset && initialCandidate?.jobId) {
|
|
1226
|
+
await authorize();
|
|
1227
|
+
job = await client.job(accountId, initialCandidate.jobId, params.signal);
|
|
1228
|
+
guard();
|
|
1229
|
+
if (job.accountId !== accountId || job.kind !== "download" || job.assetId != null && job.assetId !== initialCandidate.id) throw Error("Native download job identity does not match its attachment");
|
|
1230
|
+
assets = await scopedAssets();
|
|
1231
|
+
asset = select(assets, message.type, quality);
|
|
1232
|
+
if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1233
|
+
}
|
|
1234
|
+
if (!asset && !job && params.download !== false && !["expired", "unsupported"].includes(transferCandidate()?.state ?? "")) {
|
|
1096
1235
|
try {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
} catch {
|
|
1103
|
-
|
|
1236
|
+
await authorize();
|
|
1237
|
+
job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
|
|
1238
|
+
guard();
|
|
1239
|
+
job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
|
|
1240
|
+
guard();
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
guard();
|
|
1243
|
+
if (!(error instanceof NativeApiError) || error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
|
|
1244
|
+
transferError = error;
|
|
1104
1245
|
}
|
|
1246
|
+
assets = await scopedAssets();
|
|
1247
|
+
asset = select(assets, message.type, quality);
|
|
1248
|
+
if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1105
1249
|
}
|
|
1106
|
-
const
|
|
1250
|
+
const candidate = transferCandidate();
|
|
1251
|
+
const filename = basename(asset?.filename ?? candidate?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
|
|
1107
1252
|
if (!asset) {
|
|
1108
|
-
const state =
|
|
1109
|
-
const attachment2 = { kind: attachmentKind, filename, status: state };
|
|
1110
|
-
return {
|
|
1253
|
+
const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
|
|
1254
|
+
const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
|
|
1255
|
+
return {
|
|
1256
|
+
body: renderAttachmentBody("", attachment2),
|
|
1257
|
+
attachment: attachment2,
|
|
1258
|
+
pending: false,
|
|
1259
|
+
jobId: job?.id,
|
|
1260
|
+
retryable: state === "expired" || state === "unsupported" ? false : transferError ? false : job?.retryable ?? params.download === false,
|
|
1261
|
+
reason: transferError?.code ?? (job?.reason && /^[a-z0-9_]{1,80}$/.test(job.reason) ? job.reason : void 0)
|
|
1262
|
+
};
|
|
1111
1263
|
}
|
|
1112
1264
|
if (asset.byteLength != null && asset.byteLength > maxBytes) {
|
|
1113
1265
|
const attachment2 = { kind: attachmentKind, filename, status: "too_large" };
|
|
1114
1266
|
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
|
|
1115
1267
|
}
|
|
1116
|
-
|
|
1268
|
+
await authorize();
|
|
1269
|
+
const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
|
|
1270
|
+
guard();
|
|
1117
1271
|
const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
|
|
1118
1272
|
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
|
|
1119
1273
|
const hash = createHash4("sha256").update(content.bytes).digest("hex");
|
|
1120
1274
|
if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
|
|
1275
|
+
await authorize();
|
|
1121
1276
|
const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
|
|
1122
1277
|
const actual = asset.actualVariant ?? asset.variant;
|
|
1123
1278
|
const attachment = {
|
|
@@ -1148,9 +1303,13 @@ async function prepareNativeAttachment(params) {
|
|
|
1148
1303
|
[Model image: prepared still preview; original retained separately]`;
|
|
1149
1304
|
}
|
|
1150
1305
|
}
|
|
1306
|
+
guard();
|
|
1151
1307
|
return {
|
|
1152
1308
|
body,
|
|
1153
1309
|
attachment,
|
|
1310
|
+
jobId: job?.id,
|
|
1311
|
+
retryable: false,
|
|
1312
|
+
variant: actual,
|
|
1154
1313
|
suppliedAssets: [{
|
|
1155
1314
|
id: asset.id,
|
|
1156
1315
|
sha256: suppliedHash,
|
|
@@ -1171,7 +1330,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1171
1330
|
pending: false
|
|
1172
1331
|
};
|
|
1173
1332
|
}
|
|
1174
|
-
async function sendNativeAttachment(client, account,
|
|
1333
|
+
async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
|
|
1175
1334
|
const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
|
|
1176
1335
|
let bytes;
|
|
1177
1336
|
let filename;
|
|
@@ -1237,15 +1396,15 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1237
1396
|
if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
|
|
1238
1397
|
const accountId = account.native.accountId;
|
|
1239
1398
|
const source = await client.upload(accountId, bytes, filename, mime);
|
|
1240
|
-
const input = { threadId, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
|
|
1399
|
+
const input = { threadId: threadId2, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
|
|
1241
1400
|
const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
|
|
1242
1401
|
if (!["accepted", "completed", "succeeded"].includes(job.state)) throw new Error(`Native media job ${job.id} requires inspection (${job.state}); retain completed items and do not automatically resend`);
|
|
1243
1402
|
return `agent-wechat-native:${accountId}:${job.id}`;
|
|
1244
1403
|
}
|
|
1245
1404
|
|
|
1246
1405
|
// src/native-send.ts
|
|
1247
|
-
async function submitNativeText(client, accountId,
|
|
1248
|
-
let intent = await client.sendText(accountId,
|
|
1406
|
+
async function submitNativeText(client, accountId, threadId2, text, key, options = {}) {
|
|
1407
|
+
let intent = await client.sendText(accountId, threadId2, text, key);
|
|
1249
1408
|
const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
|
|
1250
1409
|
let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
|
|
1251
1410
|
while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
|
|
@@ -1298,6 +1457,8 @@ var NativeConsumerState = class {
|
|
|
1298
1457
|
if (parsed.schemaVersion !== 1 || parsed.serverId !== serverId || parsed.accountId !== accountId || !/^\d+$/.test(parsed.cursor) || !parsed.items || typeof parsed.items !== "object") throw new Error("Native consumer identity or schema mismatch; state was preserved");
|
|
1299
1458
|
for (const [id2, item] of Object.entries(parsed.items)) {
|
|
1300
1459
|
if (item.message?.id !== id2 || item.message.accountId !== accountId || typeof item.message.threadId !== "string" || !["received", "dispatching", "handed_off", "completed", "ignored", "needs_review"].includes(item.state)) throw new Error("Invalid native custody record; state was preserved");
|
|
1460
|
+
if (item.cursor != null && !/^\d+$/.test(item.cursor) || item.groupContext != null && typeof item.groupContext !== "boolean") throw new Error("Invalid native group context record; state was preserved");
|
|
1461
|
+
if (item.groupContext && (!item.cursor || item.origin !== "live" || item.state !== "ignored" || item.message.direction !== "incoming" || !item.message.threadId.endsWith("@chatroom"))) throw new Error("Invalid native buffered group context; state was preserved");
|
|
1301
1462
|
if (item.state === "dispatching") item.state = "needs_review";
|
|
1302
1463
|
}
|
|
1303
1464
|
this.value = parsed;
|
|
@@ -1358,10 +1519,10 @@ var NativeConsumerState = class {
|
|
|
1358
1519
|
if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
|
|
1359
1520
|
this.mutate(() => {
|
|
1360
1521
|
if (message && !this.value.items[message.id]) {
|
|
1361
|
-
const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
|
|
1522
|
+
const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && !item.groupContext && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
|
|
1362
1523
|
for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
|
|
1363
1524
|
if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
|
|
1364
|
-
this.value.items[message.id] = { message, origin, state: "received" };
|
|
1525
|
+
this.value.items[message.id] = { message, origin, state: "received", cursor };
|
|
1365
1526
|
}
|
|
1366
1527
|
this.value.cursor = cursor;
|
|
1367
1528
|
});
|
|
@@ -1371,6 +1532,30 @@ var NativeConsumerState = class {
|
|
|
1371
1532
|
if (!item) throw new Error("Unknown native custody message");
|
|
1372
1533
|
this.mutate(() => Object.assign(this.value.items[messageId], update));
|
|
1373
1534
|
}
|
|
1535
|
+
groupHistory(threadId2, beforeId, limit) {
|
|
1536
|
+
const before = this.value.items[beforeId]?.cursor;
|
|
1537
|
+
if (!before || limit <= 0) return [];
|
|
1538
|
+
return this.items().filter((item) => item.groupContext && item.state === "ignored" && item.origin === "live" && item.message.threadId === threadId2 && item.cursor && BigInt(item.cursor) < BigInt(before)).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : BigInt(a.cursor) > BigInt(b.cursor) ? 1 : 0).slice(-limit);
|
|
1539
|
+
}
|
|
1540
|
+
bufferGroupMessage(messageId, limit) {
|
|
1541
|
+
const item = this.value.items[messageId];
|
|
1542
|
+
if (!item || item.state !== "received" || item.origin !== "live" || item.message.direction !== "incoming" || !item.cursor || !item.message.threadId.endsWith("@chatroom") || !Number.isInteger(limit) || limit < 0 || limit > 200) throw new Error("Invalid native group history admission");
|
|
1543
|
+
this.mutate(() => {
|
|
1544
|
+
Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
|
|
1545
|
+
const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
|
|
1546
|
+
const thread = history.filter((value) => value.message.threadId === item.message.threadId);
|
|
1547
|
+
for (const value of thread.slice(0, Math.max(0, thread.length - limit))) value.groupContext = false;
|
|
1548
|
+
const retained = history.filter((value) => value.groupContext);
|
|
1549
|
+
for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
beginDispatch(messageId, dispatchManifest, historyIds = []) {
|
|
1553
|
+
if (this.value.items[messageId]?.state !== "received" || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
|
|
1554
|
+
this.mutate(() => {
|
|
1555
|
+
Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
|
|
1556
|
+
for (const id2 of historyIds) this.value.items[id2].groupContext = false;
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1374
1559
|
/** Synchronous bounded receipt at the SDK's prepared-run callback boundary. */
|
|
1375
1560
|
preparedCallback(messageIds, readPolicy, previous) {
|
|
1376
1561
|
if (!messageIds.length || messageIds.length > 1e3) throw new Error("Invalid native prepared input manifest");
|
|
@@ -1411,8 +1596,10 @@ function prune() {
|
|
|
1411
1596
|
for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
|
|
1412
1597
|
}
|
|
1413
1598
|
function nativeDispatchManifest(params) {
|
|
1599
|
+
const history = params.history ?? [];
|
|
1600
|
+
if (history.length > 200 || history.some((message) => message.accountId !== params.message.accountId || message.threadId !== params.message.threadId || message.id === params.message.id)) throw Error("Invalid native history manifest");
|
|
1414
1601
|
const assets = Object.freeze((params.assets ?? []).map((asset) => {
|
|
1415
|
-
if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || asset.contextRole !== "
|
|
1602
|
+
if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || !["trigger", "history"].includes(asset.contextRole) || asset.sourceMessageId != null && (typeof asset.sourceMessageId !== "string" || !asset.sourceMessageId || asset.sourceMessageId.length > 8192 || ![params.message, ...history].some((message) => message.id === asset.messageId && message.type === 49 && message.appMessageType === 57 && message.quote?.type === 3)) || asset.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
|
|
1416
1603
|
return Object.freeze({ ...asset });
|
|
1417
1604
|
}));
|
|
1418
1605
|
const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
|
|
@@ -1438,10 +1625,18 @@ function nativeDispatchManifest(params) {
|
|
|
1438
1625
|
params.message.type,
|
|
1439
1626
|
params.message.body,
|
|
1440
1627
|
params.message.mentions,
|
|
1441
|
-
params.message.serverId
|
|
1628
|
+
params.message.serverId,
|
|
1629
|
+
params.message.appMessageType,
|
|
1630
|
+
params.message.quote
|
|
1442
1631
|
])),
|
|
1443
1632
|
role: "trigger"
|
|
1444
1633
|
})]),
|
|
1634
|
+
...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
|
|
1635
|
+
id: message.id,
|
|
1636
|
+
version: message.version ?? "",
|
|
1637
|
+
contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId, message.appMessageType, message.quote])),
|
|
1638
|
+
role: "history"
|
|
1639
|
+
}))) } : {},
|
|
1445
1640
|
assets,
|
|
1446
1641
|
policySha256: digest(JSON.stringify(params.policy ?? null)),
|
|
1447
1642
|
// Exact released 2026.10.2 source-turn identity. Requiring it in the later
|
|
@@ -1568,6 +1763,93 @@ function registerNativePreparedBoundary(api) {
|
|
|
1568
1763
|
};
|
|
1569
1764
|
}
|
|
1570
1765
|
|
|
1766
|
+
// src/native-history.ts
|
|
1767
|
+
import { buildHistoryContextFromEntries, buildInboundHistoryFromEntries, DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
|
|
1768
|
+
function nativeGroupHistoryLimit(cfg) {
|
|
1769
|
+
const limit = cfg?.messages?.groupChat?.historyLimit;
|
|
1770
|
+
return Number.isInteger(limit) && limit >= 0 ? Math.min(limit, 200) : DEFAULT_GROUP_HISTORY_LIMIT;
|
|
1771
|
+
}
|
|
1772
|
+
async function transcribeNativeAttachment(prepared, core, cfg) {
|
|
1773
|
+
const attachment = prepared.attachment;
|
|
1774
|
+
if (attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) return prepared;
|
|
1775
|
+
let transcript = "";
|
|
1776
|
+
try {
|
|
1777
|
+
transcript = (await core.mediaUnderstanding.transcribeAudioFile({ filePath: attachment.path, cfg, mime: attachment.mime })).text?.trim() ?? "";
|
|
1778
|
+
} catch {
|
|
1779
|
+
}
|
|
1780
|
+
return { ...prepared, body: `${prepared.body}
|
|
1781
|
+
${transcript ? `[Voice transcript: ${transcript.slice(0, 1e4)}]` : "[Voice transcription unavailable]"}` };
|
|
1782
|
+
}
|
|
1783
|
+
async function prepareNativeHistory(messages, prepare) {
|
|
1784
|
+
const result = new Array(messages.length);
|
|
1785
|
+
let next = 0;
|
|
1786
|
+
await Promise.all(Array.from({ length: Math.min(3, messages.length) }, async () => {
|
|
1787
|
+
for (let index = next++; index < messages.length; index = next++) {
|
|
1788
|
+
const message = messages[index];
|
|
1789
|
+
let prepared;
|
|
1790
|
+
try {
|
|
1791
|
+
prepared = await prepare(message);
|
|
1792
|
+
} catch {
|
|
1793
|
+
prepared = { body: `[Attachment unavailable for message ${message.id}: retrieval or integrity verification failed]`, pending: false };
|
|
1794
|
+
}
|
|
1795
|
+
if (message.type === 1 && prepared.body.length > 4096) prepared = { ...prepared, body: `${prepared.body.slice(0, 4096)}
|
|
1796
|
+
[Message truncated; use wechat_history or wechat_search for the stored message]` };
|
|
1797
|
+
result[index] = { message, prepared };
|
|
1798
|
+
}
|
|
1799
|
+
}));
|
|
1800
|
+
return result;
|
|
1801
|
+
}
|
|
1802
|
+
function buildNativeContext(history, message, prepared) {
|
|
1803
|
+
const all = [...history, { message, prepared }];
|
|
1804
|
+
const image = history.length ? [...all].reverse().find((item) => item.prepared.attachment?.kind === "image" && item.prepared.attachment.status === "ready" && item.prepared.mediaPath) : void 0;
|
|
1805
|
+
const selected = history.length ? image : all.at(-1);
|
|
1806
|
+
const assets = all.flatMap((item) => (item.prepared.suppliedAssets ?? []).map((asset) => {
|
|
1807
|
+
const contextRole = item.message.id === message.id ? "trigger" : "history";
|
|
1808
|
+
if (item === selected) return { ...asset, contextRole, messageId: item.message.id };
|
|
1809
|
+
return {
|
|
1810
|
+
...asset,
|
|
1811
|
+
contextRole,
|
|
1812
|
+
messageId: item.message.id,
|
|
1813
|
+
runtimeMedia: false,
|
|
1814
|
+
role: "file_reference",
|
|
1815
|
+
path: asset.sourcePath ?? asset.path,
|
|
1816
|
+
sha256: asset.sourceSha256 ?? asset.sha256,
|
|
1817
|
+
byteLength: asset.sourceByteLength ?? asset.byteLength,
|
|
1818
|
+
mime: asset.sourceMime ?? asset.mime
|
|
1819
|
+
};
|
|
1820
|
+
}));
|
|
1821
|
+
const entries = history.map((item) => ({
|
|
1822
|
+
sender: item.message.senderId,
|
|
1823
|
+
body: item.prepared.body,
|
|
1824
|
+
timestamp: item.message.createdAt ? Date.parse(item.message.createdAt) : void 0,
|
|
1825
|
+
messageId: item.message.id
|
|
1826
|
+
}));
|
|
1827
|
+
const inputBody = entries.length ? buildHistoryContextFromEntries({
|
|
1828
|
+
entries,
|
|
1829
|
+
currentMessage: prepared.body,
|
|
1830
|
+
excludeLast: false,
|
|
1831
|
+
formatEntry: (entry) => `${entry.sender}: ${entry.body}`
|
|
1832
|
+
}) : prepared.body;
|
|
1833
|
+
const attachments = all.flatMap((item) => {
|
|
1834
|
+
const attachment = item.prepared.attachment;
|
|
1835
|
+
return attachment?.status === "ready" && attachment.path ? [{
|
|
1836
|
+
path: attachment.path,
|
|
1837
|
+
filename: attachment.filename,
|
|
1838
|
+
kind: attachment.kind,
|
|
1839
|
+
messageId: item.message.id,
|
|
1840
|
+
sender: item.message.senderId
|
|
1841
|
+
}] : [];
|
|
1842
|
+
});
|
|
1843
|
+
return {
|
|
1844
|
+
inputBody,
|
|
1845
|
+
attachments,
|
|
1846
|
+
assets,
|
|
1847
|
+
history: buildInboundHistoryFromEntries({ entries, limit: entries.length }),
|
|
1848
|
+
mediaPath: selected?.prepared.mediaPath,
|
|
1849
|
+
mediaMime: selected?.prepared.mediaMime
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1571
1853
|
// src/native-monitor.ts
|
|
1572
1854
|
function nativeWasMentioned(message) {
|
|
1573
1855
|
return message.mentions?.state === "known" && message.mentions.self === true;
|
|
@@ -1575,14 +1857,14 @@ function nativeWasMentioned(message) {
|
|
|
1575
1857
|
async function projectNativeRead(client, accountId, state) {
|
|
1576
1858
|
const pending2 = state.items().filter((value) => value.readPending);
|
|
1577
1859
|
const threads = [...new Set(pending2.map((item) => item.message.threadId))];
|
|
1578
|
-
for (const
|
|
1860
|
+
for (const threadId2 of threads) {
|
|
1579
1861
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1580
1862
|
let cursor;
|
|
1581
1863
|
let thread;
|
|
1582
1864
|
const seen = /* @__PURE__ */ new Set();
|
|
1583
1865
|
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
1584
1866
|
const page = await client.threads(accountId, cursor);
|
|
1585
|
-
thread = page.items.find((value) => !isNativeTombstone(value) && value.id ===
|
|
1867
|
+
thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
|
|
1586
1868
|
if (thread || !page.nextCursor) break;
|
|
1587
1869
|
if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
|
|
1588
1870
|
seen.add(page.nextCursor);
|
|
@@ -1593,7 +1875,7 @@ async function projectNativeRead(client, accountId, state) {
|
|
|
1593
1875
|
const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
|
|
1594
1876
|
const notification = receipt?.phoneNotification?.state;
|
|
1595
1877
|
const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
|
|
1596
|
-
for (const item of pending2.filter((item2) => item2.message.threadId ===
|
|
1878
|
+
for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState });
|
|
1597
1879
|
break;
|
|
1598
1880
|
} catch (error) {
|
|
1599
1881
|
if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
|
|
@@ -1616,8 +1898,20 @@ async function dispatchNativeMessage(params) {
|
|
|
1616
1898
|
const policy = resolveWeChatPolicyContext({ account, cfg, chatId: message.threadId });
|
|
1617
1899
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "agent-wechat" });
|
|
1618
1900
|
const commandBody = normalizeWeChatCommandBody(message.type === 1 ? message.body ?? "" : "", { isGroup, wasMentioned });
|
|
1901
|
+
const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
|
|
1902
|
+
const resolve = core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress;
|
|
1903
|
+
const senderAdmission = (senderId) => resolveWeChatIngress({
|
|
1904
|
+
resolve,
|
|
1905
|
+
cfg,
|
|
1906
|
+
accountId: account.accountId,
|
|
1907
|
+
policy,
|
|
1908
|
+
isGroup,
|
|
1909
|
+
chatId: message.threadId,
|
|
1910
|
+
senderId,
|
|
1911
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
|
|
1912
|
+
});
|
|
1619
1913
|
const ingress = await resolveWeChatIngress({
|
|
1620
|
-
resolve
|
|
1914
|
+
resolve,
|
|
1621
1915
|
cfg,
|
|
1622
1916
|
accountId: account.accountId,
|
|
1623
1917
|
policy,
|
|
@@ -1625,33 +1919,40 @@ async function dispatchNativeMessage(params) {
|
|
|
1625
1919
|
chatId: message.threadId,
|
|
1626
1920
|
senderId: message.senderId,
|
|
1627
1921
|
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => []),
|
|
1628
|
-
command: { allowTextCommands, hasControlCommand
|
|
1922
|
+
command: { allowTextCommands, hasControlCommand },
|
|
1629
1923
|
mention: { wasMentioned, allowTextCommands }
|
|
1630
1924
|
});
|
|
1631
1925
|
const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
|
|
1632
1926
|
if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
|
|
1927
|
+
if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
|
|
1928
|
+
state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
|
|
1929
|
+
return "ignored";
|
|
1930
|
+
}
|
|
1633
1931
|
state.update(message.id, { state: "ignored" });
|
|
1634
1932
|
return "ignored";
|
|
1635
1933
|
}
|
|
1636
1934
|
let preparedMedia;
|
|
1935
|
+
const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
|
|
1637
1936
|
try {
|
|
1638
|
-
preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg });
|
|
1937
|
+
preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg, allowQuotedMessage });
|
|
1639
1938
|
} catch {
|
|
1640
1939
|
state.update(message.id, { state: "needs_review" });
|
|
1641
1940
|
return "needs_review";
|
|
1642
1941
|
}
|
|
1643
1942
|
if (preparedMedia.pending) return "pending";
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1943
|
+
preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
|
|
1944
|
+
const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
|
|
1945
|
+
const admitted = [];
|
|
1946
|
+
for (const item of buffered) {
|
|
1947
|
+
if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
|
|
1948
|
+
}
|
|
1949
|
+
const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
|
|
1950
|
+
await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message: historical, core, cfg, allowQuotedMessage }),
|
|
1951
|
+
core,
|
|
1952
|
+
cfg
|
|
1953
|
+
));
|
|
1954
|
+
const context = buildNativeContext(history, message, preparedMedia);
|
|
1955
|
+
const inputBody = context.inputBody;
|
|
1655
1956
|
const route = core.channel.routing.resolveAgentRoute({
|
|
1656
1957
|
cfg,
|
|
1657
1958
|
channel: "agent-wechat",
|
|
@@ -1681,10 +1982,12 @@ async function dispatchNativeMessage(params) {
|
|
|
1681
1982
|
Surface: "agent-wechat",
|
|
1682
1983
|
MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
|
|
1683
1984
|
WasMentioned: isGroup ? wasMentioned : void 0,
|
|
1985
|
+
Attachments: context.attachments,
|
|
1986
|
+
InboundHistory: context.history,
|
|
1684
1987
|
CommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized(commandBody, cfg) ? ingress.commandAccess.authorized : void 0,
|
|
1685
1988
|
OriginatingChannel: "agent-wechat",
|
|
1686
1989
|
OriginatingTo: `wechat:${message.threadId}`,
|
|
1687
|
-
...
|
|
1990
|
+
...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
|
|
1688
1991
|
});
|
|
1689
1992
|
await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
|
|
1690
1993
|
const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
|
|
@@ -1696,10 +1999,11 @@ async function dispatchNativeMessage(params) {
|
|
|
1696
1999
|
agentId: route.agentId,
|
|
1697
2000
|
channelAccountId: account.accountId,
|
|
1698
2001
|
policy,
|
|
1699
|
-
assets:
|
|
2002
|
+
assets: context.assets,
|
|
2003
|
+
history: admitted
|
|
1700
2004
|
});
|
|
1701
|
-
state.
|
|
1702
|
-
const callback = readPolicy !== "on_inbound" && (message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
|
|
2005
|
+
state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
|
|
2006
|
+
const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
|
|
1703
2007
|
if (callback) trackNativePreparedInput(dispatchManifest, callback);
|
|
1704
2008
|
let failed = false;
|
|
1705
2009
|
let delivery = 0;
|
|
@@ -1903,10 +2207,10 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
|
|
|
1903
2207
|
}
|
|
1904
2208
|
return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
|
|
1905
2209
|
}
|
|
1906
|
-
async function sendNativeText(account,
|
|
2210
|
+
async function sendNativeText(account, threadId2, text, key = randomUUID5()) {
|
|
1907
2211
|
const native = account.native;
|
|
1908
2212
|
const { client } = await ensureNativeHost(native);
|
|
1909
|
-
const send = await submitNativeText(client, native.accountId,
|
|
2213
|
+
const send = await submitNativeText(client, native.accountId, threadId2, text, key);
|
|
1910
2214
|
return `agent-wechat-native:${native.accountId}:${send.id}`;
|
|
1911
2215
|
}
|
|
1912
2216
|
function createNativeLoginTool(account) {
|
|
@@ -2357,6 +2661,243 @@ function registerNativeFacade(api, config) {
|
|
|
2357
2661
|
return handler.stop;
|
|
2358
2662
|
}
|
|
2359
2663
|
|
|
2664
|
+
// src/native-history-tools.ts
|
|
2665
|
+
import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress2 } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
2666
|
+
import { imageResultFromFile } from "openclaw/plugin-sdk/channel-actions";
|
|
2667
|
+
var pageSchema = { limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, cursor: { type: "string", maxLength: 8192, description: "Opaque nextCursor from the same tool and query; do not invent one." } };
|
|
2668
|
+
var names = ["wechat_history", "wechat_search", "wechat_message", "wechat_media"];
|
|
2669
|
+
var archiveNames = ["wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"];
|
|
2670
|
+
var messageIdSchema = { type: "string", minLength: 1, maxLength: 4096 };
|
|
2671
|
+
var qualities = ["best", "original", "standard", "thumbnail", "normal", "encoded"];
|
|
2672
|
+
function object(value) {
|
|
2673
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
|
|
2674
|
+
return value;
|
|
2675
|
+
}
|
|
2676
|
+
function string(value, name, max = 4096) {
|
|
2677
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name}`);
|
|
2678
|
+
return value.trim();
|
|
2679
|
+
}
|
|
2680
|
+
function threadId(value) {
|
|
2681
|
+
return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
|
|
2682
|
+
}
|
|
2683
|
+
function messageResult(message, retrievalTool = "wechat_media") {
|
|
2684
|
+
const isQuote = message.type === 49 && message.appMessageType === 57;
|
|
2685
|
+
const isForward = message.type === 49 && message.appMessageType === 19;
|
|
2686
|
+
const kind2 = isQuote ? "quote" : isForward ? "forwarded_history" : message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 && (message.appMessageType == null || message.appMessageType === 6) ? "file" : "unsupported";
|
|
2687
|
+
const body = message.type === 1 || isForward ? message.body ?? "" : isQuote ? `${message.body ?? ""}
|
|
2688
|
+
[Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
|
|
2689
|
+
return {
|
|
2690
|
+
id: message.id,
|
|
2691
|
+
threadId: message.threadId,
|
|
2692
|
+
serverId: message.serverId,
|
|
2693
|
+
senderId: message.senderId,
|
|
2694
|
+
direction: message.direction,
|
|
2695
|
+
type: message.type,
|
|
2696
|
+
appMessageType: message.appMessageType,
|
|
2697
|
+
quote: message.quote,
|
|
2698
|
+
createdAt: message.createdAt,
|
|
2699
|
+
body: body.slice(0, 4e3),
|
|
2700
|
+
bodyTruncated: body.length > 4e3,
|
|
2701
|
+
attachment: message.type !== 1 && !isForward && kind2 !== "unsupported" ? { kind: kind2, retrievalTool, messageId: message.id } : void 0
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
function createNativeHistoryTools(context, dependencies = {}) {
|
|
2705
|
+
return createLookupTools(context, dependencies, false);
|
|
2706
|
+
}
|
|
2707
|
+
function createNativeArchiveTools(context, dependencies = {}) {
|
|
2708
|
+
return createLookupTools(context, dependencies, true);
|
|
2709
|
+
}
|
|
2710
|
+
function createLookupTools(context, dependencies, archive) {
|
|
2711
|
+
const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
|
|
2712
|
+
if (!currentConfig()) return [];
|
|
2713
|
+
const selected = resolveWeChatAccount(currentConfig());
|
|
2714
|
+
if (!selected?.native || !selected.enabled) return [];
|
|
2715
|
+
if (!archive && context.messageChannel !== "agent-wechat") return [];
|
|
2716
|
+
if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
|
|
2717
|
+
if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
2718
|
+
let currentThread;
|
|
2719
|
+
let requesterSenderId;
|
|
2720
|
+
const target = context.deliveryContext?.channel === "agent-wechat" ? context.deliveryContext.to : void 0;
|
|
2721
|
+
if (!archive) {
|
|
2722
|
+
try {
|
|
2723
|
+
if (context.nativeChannelId) currentThread = threadId(context.nativeChannelId);
|
|
2724
|
+
if (target) {
|
|
2725
|
+
const deliveryThread = threadId(target);
|
|
2726
|
+
if (currentThread && currentThread !== deliveryThread) return [];
|
|
2727
|
+
currentThread ??= deliveryThread;
|
|
2728
|
+
}
|
|
2729
|
+
requesterSenderId = string(context.requesterSenderId, "requester sender ID");
|
|
2730
|
+
} catch {
|
|
2731
|
+
return [];
|
|
2732
|
+
}
|
|
2733
|
+
if (!currentThread) return [];
|
|
2734
|
+
}
|
|
2735
|
+
const selectedId = selected.native.accountId;
|
|
2736
|
+
const tool = (name, action, description, properties, required = []) => ({
|
|
2737
|
+
name,
|
|
2738
|
+
label: name.replaceAll("_", " "),
|
|
2739
|
+
description,
|
|
2740
|
+
// The pinned host's tool_call wrapper JSON-serializes image blocks. Its
|
|
2741
|
+
// public direct-only surface preserves actual image content for vision.
|
|
2742
|
+
...action === "media" ? { catalogMode: "direct-only" } : {},
|
|
2743
|
+
parameters: { type: "object", additionalProperties: false, properties, required },
|
|
2744
|
+
execute: async (_callId, input, signal) => {
|
|
2745
|
+
const args = object(input);
|
|
2746
|
+
const guard = () => {
|
|
2747
|
+
signal?.throwIfAborted();
|
|
2748
|
+
context.assertInvocationCurrent?.();
|
|
2749
|
+
const cfg2 = currentConfig();
|
|
2750
|
+
const account2 = resolveWeChatAccount(cfg2);
|
|
2751
|
+
if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId) throw Error("WeChat account configuration changed; start a new turn");
|
|
2752
|
+
return { cfg: cfg2, account: account2, native: account2.native };
|
|
2753
|
+
};
|
|
2754
|
+
const { cfg, account, native } = guard();
|
|
2755
|
+
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");
|
|
2756
|
+
const limit = args.limit ?? 20;
|
|
2757
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
|
|
2758
|
+
const cursor = args.cursor === void 0 ? void 0 : string(args.cursor, "cursor", 8192);
|
|
2759
|
+
const query = args.query === void 0 ? void 0 : string(args.query, "query", 256);
|
|
2760
|
+
if (query && Buffer.byteLength(query) > 256) throw Error("Search query exceeds 256 UTF-8 bytes");
|
|
2761
|
+
if (action === "search" && !query) throw Error("A non-empty search query is required");
|
|
2762
|
+
const conversation = archive ? args.threadId === void 0 ? void 0 : threadId(args.threadId) : currentThread;
|
|
2763
|
+
if (action === "history" && !conversation) throw Error("A thread ID is required");
|
|
2764
|
+
const messageId = Object.hasOwn(properties, "messageId") ? string(args.messageId, "message ID") : void 0;
|
|
2765
|
+
if (args.download !== void 0 && typeof args.download !== "boolean") throw Error("download must be a boolean");
|
|
2766
|
+
const quality = args.quality ?? "best";
|
|
2767
|
+
if (!qualities.includes(quality)) throw Error("Invalid media quality");
|
|
2768
|
+
const core = dependencies.core ?? getWeChatRuntime();
|
|
2769
|
+
const allowed = async (sender) => {
|
|
2770
|
+
if (archive) return true;
|
|
2771
|
+
const { cfg: cfg2, account: account2 } = guard();
|
|
2772
|
+
const result2 = await resolveWeChatIngress({
|
|
2773
|
+
resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
|
|
2774
|
+
cfg: cfg2,
|
|
2775
|
+
accountId: account2.accountId,
|
|
2776
|
+
policy: resolveWeChatPolicyContext({ account: account2, cfg: cfg2, chatId: conversation }),
|
|
2777
|
+
isGroup: conversation.endsWith("@chatroom"),
|
|
2778
|
+
chatId: conversation,
|
|
2779
|
+
senderId: sender,
|
|
2780
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account2.accountId, env: process.env }).catch(() => [])
|
|
2781
|
+
});
|
|
2782
|
+
return result2.ingress.admission === "dispatch";
|
|
2783
|
+
};
|
|
2784
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
2785
|
+
guard();
|
|
2786
|
+
const { client } = await (dependencies.host ?? ensureNativeHost)(native);
|
|
2787
|
+
guard();
|
|
2788
|
+
const accountId = native.accountId;
|
|
2789
|
+
let result;
|
|
2790
|
+
if (action === "threads") {
|
|
2791
|
+
const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
|
|
2792
|
+
const items = [];
|
|
2793
|
+
for (const thread of page.items) {
|
|
2794
|
+
if (isNativeTombstone(thread)) continue;
|
|
2795
|
+
if (thread.accountId !== accountId) throw Error("Thread response crosses an account boundary");
|
|
2796
|
+
items.push({ id: thread.id, peerId: thread.peerId, kind: thread.kind, displayName: thread.displayName, unreadCount: thread.unreadCount });
|
|
2797
|
+
}
|
|
2798
|
+
result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
|
|
2799
|
+
} else if (action === "message" || action === "media") {
|
|
2800
|
+
const message = await client.message(accountId, messageId);
|
|
2801
|
+
guard();
|
|
2802
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
2803
|
+
if (message.id !== messageId || message.accountId !== accountId || !archive && message.threadId !== conversation || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("Message is outside the permitted conversation or sender policy");
|
|
2804
|
+
result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
|
|
2805
|
+
if (action === "media") {
|
|
2806
|
+
let prepared;
|
|
2807
|
+
try {
|
|
2808
|
+
prepared = await prepareNativeAttachment({
|
|
2809
|
+
client,
|
|
2810
|
+
account,
|
|
2811
|
+
message,
|
|
2812
|
+
core,
|
|
2813
|
+
cfg,
|
|
2814
|
+
quality,
|
|
2815
|
+
download: args.download !== false,
|
|
2816
|
+
waitMs: 2e3,
|
|
2817
|
+
signal,
|
|
2818
|
+
assertCurrent: guard,
|
|
2819
|
+
allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
|
|
2820
|
+
beforeMediaAccess: async () => {
|
|
2821
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
guard();
|
|
2826
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
2827
|
+
const details2 = {
|
|
2828
|
+
messageId: message.id,
|
|
2829
|
+
threadId: message.threadId,
|
|
2830
|
+
status: error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
|
|
2831
|
+
retryable: error.retryable,
|
|
2832
|
+
reason: error.code
|
|
2833
|
+
};
|
|
2834
|
+
return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
|
|
2835
|
+
}
|
|
2836
|
+
guard();
|
|
2837
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
2838
|
+
const details = {
|
|
2839
|
+
messageId: message.id,
|
|
2840
|
+
threadId: message.threadId,
|
|
2841
|
+
status: prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
|
|
2842
|
+
attachment: prepared.attachment,
|
|
2843
|
+
quote: prepared.quote,
|
|
2844
|
+
variant: prepared.variant,
|
|
2845
|
+
jobId: prepared.jobId,
|
|
2846
|
+
retryable: prepared.retryable ?? false,
|
|
2847
|
+
reason: prepared.reason,
|
|
2848
|
+
body: prepared.body
|
|
2849
|
+
};
|
|
2850
|
+
const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
|
|
2851
|
+
const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
2852
|
+
guard();
|
|
2853
|
+
return output;
|
|
2854
|
+
}
|
|
2855
|
+
} else {
|
|
2856
|
+
const options = { limit, cursor, ...action === "search" ? { query } : {} };
|
|
2857
|
+
const page = conversation ? await client.messages(accountId, conversation, options) : await client.searchMessages(accountId, options);
|
|
2858
|
+
const items = [];
|
|
2859
|
+
for (const message of page.items) {
|
|
2860
|
+
if (isNativeTombstone(message)) continue;
|
|
2861
|
+
if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
|
|
2862
|
+
if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
|
|
2863
|
+
}
|
|
2864
|
+
result = {
|
|
2865
|
+
...page,
|
|
2866
|
+
items,
|
|
2867
|
+
scope: "local_profile",
|
|
2868
|
+
searchMode: action === "search" ? "case_insensitive_substring" : void 0,
|
|
2869
|
+
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."
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
guard();
|
|
2873
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
const querySchema = { type: "string", minLength: 1, maxLength: 256 };
|
|
2877
|
+
const mediaSchema = {
|
|
2878
|
+
messageId: messageIdSchema,
|
|
2879
|
+
download: { type: "boolean", default: true, description: "Set false to use cached media only without requesting a WeChat transfer." },
|
|
2880
|
+
quality: { type: "string", enum: qualities, default: "best", description: "best uses the best cached image, normal video or decoded voice. Explicit variants are strict and may be unavailable." }
|
|
2881
|
+
};
|
|
2882
|
+
if (archive) return [
|
|
2883
|
+
tool(archiveNames[0], "threads", "List or find locally stored threads across the configured WeChat account. Optional case-insensitive query matches thread IDs and display names. Includes groups and peers, with opaque pagination; no read marking.", { query: querySchema, ...pageSchema }),
|
|
2884
|
+
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"]),
|
|
2885
|
+
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"]),
|
|
2886
|
+
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"]),
|
|
2887
|
+
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"])
|
|
2888
|
+
];
|
|
2889
|
+
return [
|
|
2890
|
+
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 }),
|
|
2891
|
+
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"]),
|
|
2892
|
+
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"]),
|
|
2893
|
+
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"])
|
|
2894
|
+
];
|
|
2895
|
+
}
|
|
2896
|
+
function registerNativeHistoryTools(api) {
|
|
2897
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeHistoryTools(context) }, { names });
|
|
2898
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2360
2901
|
// index.ts
|
|
2361
2902
|
var plugin = {
|
|
2362
2903
|
id: "agent-wechat",
|
|
@@ -2367,6 +2908,7 @@ var plugin = {
|
|
|
2367
2908
|
setWeChatRuntime(api.runtime);
|
|
2368
2909
|
const selected = resolveWeChatAccount(api.config);
|
|
2369
2910
|
api.registerChannel({ plugin: wechatPlugin });
|
|
2911
|
+
registerNativeHistoryTools(api);
|
|
2370
2912
|
const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
|
|
2371
2913
|
};
|
|
2372
2914
|
const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "agent-wechat",
|
|
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"]
|
|
5
|
+
},
|
|
6
|
+
"toolMetadata": {
|
|
7
|
+
"wechat_archive_threads": { "optional": true },
|
|
8
|
+
"wechat_archive_search": { "optional": true },
|
|
9
|
+
"wechat_archive_history": { "optional": true },
|
|
10
|
+
"wechat_archive_message": { "optional": true },
|
|
11
|
+
"wechat_archive_media": { "optional": true }
|
|
12
|
+
},
|
|
3
13
|
"legacyPluginIds": [
|
|
4
14
|
"wechat"
|
|
5
15
|
],
|