@agent-wechat/agent-wechat 1.0.0-beta.5 → 1.0.0-beta.6
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 +503 -59
- 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,21 @@ var NativeWeChatClient = class {
|
|
|
377
377
|
throw new Error("Invalid native message identity");
|
|
378
378
|
return message;
|
|
379
379
|
}
|
|
380
|
-
|
|
380
|
+
/** Account-wide local search, with the daemon's query-bound opaque cursor. */
|
|
381
|
+
async searchMessages(accountId, options) {
|
|
382
|
+
const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
|
|
383
|
+
for (const message of result.items) {
|
|
384
|
+
if (isNativeTombstone(message))
|
|
385
|
+
continue;
|
|
386
|
+
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)))
|
|
387
|
+
throw new Error("Invalid native archive message identity");
|
|
388
|
+
}
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
sendText(accountId, threadId2, text, idempotencyKey) {
|
|
381
392
|
if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
|
|
382
393
|
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 }) });
|
|
394
|
+
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text }) });
|
|
384
395
|
}
|
|
385
396
|
send(accountId, sendId, signal) {
|
|
386
397
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
|
|
@@ -440,10 +451,10 @@ var NativeWeChatClient = class {
|
|
|
440
451
|
}
|
|
441
452
|
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
453
|
}
|
|
443
|
-
markRead(accountId,
|
|
454
|
+
markRead(accountId, threadId2, expectedVersion) {
|
|
444
455
|
if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
|
|
445
456
|
throw new Error("Read version is required");
|
|
446
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
457
|
+
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
|
|
447
458
|
}
|
|
448
459
|
events(accountId, after = "0", limit = 100) {
|
|
449
460
|
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
@@ -1024,17 +1035,18 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
|
|
|
1024
1035
|
if (attachment.kind === "file") {
|
|
1025
1036
|
const prefix = originalBody.trim() === name ? "" : originalBody;
|
|
1026
1037
|
return appendReference(prefix, `[File: ${name}]
|
|
1027
|
-
[Local file:
|
|
1038
|
+
[Local file: <${attachment.path}>]`);
|
|
1028
1039
|
}
|
|
1029
1040
|
if (exposeNonVisualPath) {
|
|
1030
1041
|
if (attachment.kind === "audio") {
|
|
1031
|
-
return appendReference(originalBody || "<media:audio>", `[Voice message file:
|
|
1042
|
+
return appendReference(originalBody || "<media:audio>", `[Voice message file: <${attachment.path}>]`);
|
|
1032
1043
|
}
|
|
1033
1044
|
if (attachment.kind === "video") {
|
|
1034
|
-
return appendReference(originalBody || "<media:video>", `[Video file:
|
|
1045
|
+
return appendReference(originalBody || "<media:video>", `[Video file: <${attachment.path}>]`);
|
|
1035
1046
|
}
|
|
1036
1047
|
}
|
|
1037
|
-
|
|
1048
|
+
let body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
|
|
1049
|
+
if (attachment.kind === "image") body = appendReference(body, `[Image file: <${attachment.path}>]`);
|
|
1038
1050
|
if (attachment.kind === "image" && attachment.quality === "thumbnail") {
|
|
1039
1051
|
return appendReference(body, "[Image preview only: thumbnail quality]");
|
|
1040
1052
|
}
|
|
@@ -1046,26 +1058,30 @@ function kind(type) {
|
|
|
1046
1058
|
return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
|
|
1047
1059
|
}
|
|
1048
1060
|
var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
|
|
1049
|
-
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3) {
|
|
1061
|
+
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
|
|
1050
1062
|
const deadline = Date.now() + timeoutMs;
|
|
1051
1063
|
let job = initial;
|
|
1052
1064
|
let delay = 250;
|
|
1053
1065
|
while (!terminalJobs.has(job.state) && Date.now() < deadline) {
|
|
1066
|
+
signal?.throwIfAborted();
|
|
1067
|
+
assertCurrent?.();
|
|
1054
1068
|
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
|
|
1055
1069
|
const remaining = deadline - Date.now();
|
|
1056
1070
|
if (remaining <= 0) break;
|
|
1057
|
-
|
|
1071
|
+
signal?.throwIfAborted();
|
|
1072
|
+
assertCurrent?.();
|
|
1073
|
+
job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
|
|
1058
1074
|
delay = Math.min(delay * 2, 1e3);
|
|
1059
1075
|
}
|
|
1060
1076
|
return job;
|
|
1061
1077
|
}
|
|
1062
|
-
function select(assets, type) {
|
|
1078
|
+
function select(assets, type, quality = "best") {
|
|
1063
1079
|
const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
|
|
1064
1080
|
const rank = (asset) => {
|
|
1065
1081
|
const index = preferred.indexOf(asset.actualVariant ?? asset.variant);
|
|
1066
1082
|
return index < 0 ? preferred.length : index;
|
|
1067
1083
|
};
|
|
1068
|
-
return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (type !== 34 || (asset.actualVariant ?? asset.variant) === "normal"));
|
|
1084
|
+
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
1085
|
}
|
|
1070
1086
|
async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
1071
1087
|
const configured = cfg?.agents?.defaults?.imageMaxDimensionPx;
|
|
@@ -1082,42 +1098,93 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
|
1082
1098
|
async function prepareNativeAttachment(params) {
|
|
1083
1099
|
const { client, account, message, core } = params;
|
|
1084
1100
|
const attachmentKind = kind(message.type);
|
|
1101
|
+
const guard = () => {
|
|
1102
|
+
params.signal?.throwIfAborted();
|
|
1103
|
+
params.assertCurrent?.();
|
|
1104
|
+
};
|
|
1105
|
+
guard();
|
|
1106
|
+
const authorize = async () => {
|
|
1107
|
+
guard();
|
|
1108
|
+
await params.beforeMediaAccess?.();
|
|
1109
|
+
guard();
|
|
1110
|
+
};
|
|
1085
1111
|
if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
|
|
1086
1112
|
const accountId = account.native.accountId;
|
|
1087
1113
|
const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
|
|
1088
1114
|
const scopedAssets = async () => {
|
|
1115
|
+
await authorize();
|
|
1089
1116
|
const items = (await client.mediaAssets(accountId, message.id)).items;
|
|
1117
|
+
guard();
|
|
1090
1118
|
if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
|
|
1091
1119
|
return items;
|
|
1092
1120
|
};
|
|
1121
|
+
const quality = params.quality ?? "best";
|
|
1122
|
+
const transferQuality = quality === "best" && (message.type === 34 || message.type === 43) ? "normal" : quality;
|
|
1093
1123
|
let assets = await scopedAssets();
|
|
1094
|
-
let asset = select(assets, message.type);
|
|
1095
|
-
|
|
1124
|
+
let asset = select(assets, message.type, quality);
|
|
1125
|
+
const relevant = () => assets.filter((value) => transferQuality === "best" || value.variant === transferQuality);
|
|
1126
|
+
const transferCandidate = () => {
|
|
1127
|
+
const order = ["original", "normal", "standard", "thumbnail", "encoded"];
|
|
1128
|
+
return [...relevant()].sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant))[0];
|
|
1129
|
+
};
|
|
1130
|
+
const initialCandidate = transferCandidate();
|
|
1131
|
+
if (!asset && initialCandidate?.byteLength != null && initialCandidate.byteLength > maxBytes) {
|
|
1132
|
+
const attachment2 = { kind: attachmentKind, filename: basename(initialCandidate.filename ?? "attachment"), status: "too_large" };
|
|
1133
|
+
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false, retryable: false };
|
|
1134
|
+
}
|
|
1135
|
+
let job;
|
|
1136
|
+
let transferError;
|
|
1137
|
+
if (!asset && initialCandidate?.jobId) {
|
|
1138
|
+
await authorize();
|
|
1139
|
+
job = await client.job(accountId, initialCandidate.jobId, params.signal);
|
|
1140
|
+
guard();
|
|
1141
|
+
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");
|
|
1142
|
+
assets = await scopedAssets();
|
|
1143
|
+
asset = select(assets, message.type, quality);
|
|
1144
|
+
if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1145
|
+
}
|
|
1146
|
+
if (!asset && !job && params.download !== false && !["expired", "unsupported"].includes(transferCandidate()?.state ?? "")) {
|
|
1096
1147
|
try {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
} catch {
|
|
1103
|
-
|
|
1148
|
+
await authorize();
|
|
1149
|
+
job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
|
|
1150
|
+
guard();
|
|
1151
|
+
job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
|
|
1152
|
+
guard();
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
guard();
|
|
1155
|
+
if (!(error instanceof NativeApiError) || error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
|
|
1156
|
+
transferError = error;
|
|
1104
1157
|
}
|
|
1158
|
+
assets = await scopedAssets();
|
|
1159
|
+
asset = select(assets, message.type, quality);
|
|
1160
|
+
if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1105
1161
|
}
|
|
1106
|
-
const
|
|
1162
|
+
const candidate = transferCandidate();
|
|
1163
|
+
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
1164
|
if (!asset) {
|
|
1108
|
-
const state =
|
|
1109
|
-
const attachment2 = { kind: attachmentKind, filename, status: state };
|
|
1110
|
-
return {
|
|
1165
|
+
const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
|
|
1166
|
+
const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
|
|
1167
|
+
return {
|
|
1168
|
+
body: renderAttachmentBody("", attachment2),
|
|
1169
|
+
attachment: attachment2,
|
|
1170
|
+
pending: false,
|
|
1171
|
+
jobId: job?.id,
|
|
1172
|
+
retryable: state === "expired" || state === "unsupported" ? false : transferError ? false : job?.retryable ?? params.download === false,
|
|
1173
|
+
reason: transferError?.code ?? (job?.reason && /^[a-z0-9_]{1,80}$/.test(job.reason) ? job.reason : void 0)
|
|
1174
|
+
};
|
|
1111
1175
|
}
|
|
1112
1176
|
if (asset.byteLength != null && asset.byteLength > maxBytes) {
|
|
1113
1177
|
const attachment2 = { kind: attachmentKind, filename, status: "too_large" };
|
|
1114
1178
|
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
|
|
1115
1179
|
}
|
|
1116
|
-
|
|
1180
|
+
await authorize();
|
|
1181
|
+
const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
|
|
1182
|
+
guard();
|
|
1117
1183
|
const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
|
|
1118
1184
|
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
|
|
1119
1185
|
const hash = createHash4("sha256").update(content.bytes).digest("hex");
|
|
1120
1186
|
if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
|
|
1187
|
+
await authorize();
|
|
1121
1188
|
const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
|
|
1122
1189
|
const actual = asset.actualVariant ?? asset.variant;
|
|
1123
1190
|
const attachment = {
|
|
@@ -1148,9 +1215,13 @@ async function prepareNativeAttachment(params) {
|
|
|
1148
1215
|
[Model image: prepared still preview; original retained separately]`;
|
|
1149
1216
|
}
|
|
1150
1217
|
}
|
|
1218
|
+
guard();
|
|
1151
1219
|
return {
|
|
1152
1220
|
body,
|
|
1153
1221
|
attachment,
|
|
1222
|
+
jobId: job?.id,
|
|
1223
|
+
retryable: false,
|
|
1224
|
+
variant: actual,
|
|
1154
1225
|
suppliedAssets: [{
|
|
1155
1226
|
id: asset.id,
|
|
1156
1227
|
sha256: suppliedHash,
|
|
@@ -1171,7 +1242,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1171
1242
|
pending: false
|
|
1172
1243
|
};
|
|
1173
1244
|
}
|
|
1174
|
-
async function sendNativeAttachment(client, account,
|
|
1245
|
+
async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
|
|
1175
1246
|
const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
|
|
1176
1247
|
let bytes;
|
|
1177
1248
|
let filename;
|
|
@@ -1237,15 +1308,15 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1237
1308
|
if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
|
|
1238
1309
|
const accountId = account.native.accountId;
|
|
1239
1310
|
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 };
|
|
1311
|
+
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
1312
|
const job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
|
|
1242
1313
|
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
1314
|
return `agent-wechat-native:${accountId}:${job.id}`;
|
|
1244
1315
|
}
|
|
1245
1316
|
|
|
1246
1317
|
// src/native-send.ts
|
|
1247
|
-
async function submitNativeText(client, accountId,
|
|
1248
|
-
let intent = await client.sendText(accountId,
|
|
1318
|
+
async function submitNativeText(client, accountId, threadId2, text, key, options = {}) {
|
|
1319
|
+
let intent = await client.sendText(accountId, threadId2, text, key);
|
|
1249
1320
|
const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
|
|
1250
1321
|
let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
|
|
1251
1322
|
while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
|
|
@@ -1298,6 +1369,8 @@ var NativeConsumerState = class {
|
|
|
1298
1369
|
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
1370
|
for (const [id2, item] of Object.entries(parsed.items)) {
|
|
1300
1371
|
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");
|
|
1372
|
+
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");
|
|
1373
|
+
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
1374
|
if (item.state === "dispatching") item.state = "needs_review";
|
|
1302
1375
|
}
|
|
1303
1376
|
this.value = parsed;
|
|
@@ -1358,10 +1431,10 @@ var NativeConsumerState = class {
|
|
|
1358
1431
|
if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
|
|
1359
1432
|
this.mutate(() => {
|
|
1360
1433
|
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));
|
|
1434
|
+
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
1435
|
for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
|
|
1363
1436
|
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" };
|
|
1437
|
+
this.value.items[message.id] = { message, origin, state: "received", cursor };
|
|
1365
1438
|
}
|
|
1366
1439
|
this.value.cursor = cursor;
|
|
1367
1440
|
});
|
|
@@ -1371,6 +1444,30 @@ var NativeConsumerState = class {
|
|
|
1371
1444
|
if (!item) throw new Error("Unknown native custody message");
|
|
1372
1445
|
this.mutate(() => Object.assign(this.value.items[messageId], update));
|
|
1373
1446
|
}
|
|
1447
|
+
groupHistory(threadId2, beforeId, limit) {
|
|
1448
|
+
const before = this.value.items[beforeId]?.cursor;
|
|
1449
|
+
if (!before || limit <= 0) return [];
|
|
1450
|
+
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);
|
|
1451
|
+
}
|
|
1452
|
+
bufferGroupMessage(messageId, limit) {
|
|
1453
|
+
const item = this.value.items[messageId];
|
|
1454
|
+
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");
|
|
1455
|
+
this.mutate(() => {
|
|
1456
|
+
Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
|
|
1457
|
+
const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
|
|
1458
|
+
const thread = history.filter((value) => value.message.threadId === item.message.threadId);
|
|
1459
|
+
for (const value of thread.slice(0, Math.max(0, thread.length - limit))) value.groupContext = false;
|
|
1460
|
+
const retained = history.filter((value) => value.groupContext);
|
|
1461
|
+
for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
beginDispatch(messageId, dispatchManifest, historyIds = []) {
|
|
1465
|
+
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");
|
|
1466
|
+
this.mutate(() => {
|
|
1467
|
+
Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
|
|
1468
|
+
for (const id2 of historyIds) this.value.items[id2].groupContext = false;
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1374
1471
|
/** Synchronous bounded receipt at the SDK's prepared-run callback boundary. */
|
|
1375
1472
|
preparedCallback(messageIds, readPolicy, previous) {
|
|
1376
1473
|
if (!messageIds.length || messageIds.length > 1e3) throw new Error("Invalid native prepared input manifest");
|
|
@@ -1411,8 +1508,10 @@ function prune() {
|
|
|
1411
1508
|
for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
|
|
1412
1509
|
}
|
|
1413
1510
|
function nativeDispatchManifest(params) {
|
|
1511
|
+
const history = params.history ?? [];
|
|
1512
|
+
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
1513
|
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
|
|
1514
|
+
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.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
|
|
1416
1515
|
return Object.freeze({ ...asset });
|
|
1417
1516
|
}));
|
|
1418
1517
|
const messageSid = `wechat-native:${params.message.accountId}:${params.message.id}`;
|
|
@@ -1442,6 +1541,12 @@ function nativeDispatchManifest(params) {
|
|
|
1442
1541
|
])),
|
|
1443
1542
|
role: "trigger"
|
|
1444
1543
|
})]),
|
|
1544
|
+
...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
|
|
1545
|
+
id: message.id,
|
|
1546
|
+
version: message.version ?? "",
|
|
1547
|
+
contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId])),
|
|
1548
|
+
role: "history"
|
|
1549
|
+
}))) } : {},
|
|
1445
1550
|
assets,
|
|
1446
1551
|
policySha256: digest(JSON.stringify(params.policy ?? null)),
|
|
1447
1552
|
// Exact released 2026.10.2 source-turn identity. Requiring it in the later
|
|
@@ -1568,6 +1673,93 @@ function registerNativePreparedBoundary(api) {
|
|
|
1568
1673
|
};
|
|
1569
1674
|
}
|
|
1570
1675
|
|
|
1676
|
+
// src/native-history.ts
|
|
1677
|
+
import { buildHistoryContextFromEntries, buildInboundHistoryFromEntries, DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
|
|
1678
|
+
function nativeGroupHistoryLimit(cfg) {
|
|
1679
|
+
const limit = cfg?.messages?.groupChat?.historyLimit;
|
|
1680
|
+
return Number.isInteger(limit) && limit >= 0 ? Math.min(limit, 200) : DEFAULT_GROUP_HISTORY_LIMIT;
|
|
1681
|
+
}
|
|
1682
|
+
async function transcribeNativeAttachment(prepared, core, cfg) {
|
|
1683
|
+
const attachment = prepared.attachment;
|
|
1684
|
+
if (attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) return prepared;
|
|
1685
|
+
let transcript = "";
|
|
1686
|
+
try {
|
|
1687
|
+
transcript = (await core.mediaUnderstanding.transcribeAudioFile({ filePath: attachment.path, cfg, mime: attachment.mime })).text?.trim() ?? "";
|
|
1688
|
+
} catch {
|
|
1689
|
+
}
|
|
1690
|
+
return { ...prepared, body: `${prepared.body}
|
|
1691
|
+
${transcript ? `[Voice transcript: ${transcript.slice(0, 1e4)}]` : "[Voice transcription unavailable]"}` };
|
|
1692
|
+
}
|
|
1693
|
+
async function prepareNativeHistory(messages, prepare) {
|
|
1694
|
+
const result = new Array(messages.length);
|
|
1695
|
+
let next = 0;
|
|
1696
|
+
await Promise.all(Array.from({ length: Math.min(3, messages.length) }, async () => {
|
|
1697
|
+
for (let index = next++; index < messages.length; index = next++) {
|
|
1698
|
+
const message = messages[index];
|
|
1699
|
+
let prepared;
|
|
1700
|
+
try {
|
|
1701
|
+
prepared = await prepare(message);
|
|
1702
|
+
} catch {
|
|
1703
|
+
prepared = { body: `[Attachment unavailable for message ${message.id}: retrieval or integrity verification failed]`, pending: false };
|
|
1704
|
+
}
|
|
1705
|
+
if (message.type === 1 && prepared.body.length > 4096) prepared = { ...prepared, body: `${prepared.body.slice(0, 4096)}
|
|
1706
|
+
[Message truncated; use wechat_history or wechat_search for the stored message]` };
|
|
1707
|
+
result[index] = { message, prepared };
|
|
1708
|
+
}
|
|
1709
|
+
}));
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1712
|
+
function buildNativeContext(history, message, prepared) {
|
|
1713
|
+
const all = [...history, { message, prepared }];
|
|
1714
|
+
const image = history.length ? [...all].reverse().find((item) => item.prepared.attachment?.kind === "image" && item.prepared.attachment.status === "ready" && item.prepared.mediaPath) : void 0;
|
|
1715
|
+
const selected = history.length ? image : all.at(-1);
|
|
1716
|
+
const assets = all.flatMap((item) => (item.prepared.suppliedAssets ?? []).map((asset) => {
|
|
1717
|
+
const contextRole = item.message.id === message.id ? "trigger" : "history";
|
|
1718
|
+
if (item === selected) return { ...asset, contextRole, messageId: item.message.id };
|
|
1719
|
+
return {
|
|
1720
|
+
...asset,
|
|
1721
|
+
contextRole,
|
|
1722
|
+
messageId: item.message.id,
|
|
1723
|
+
runtimeMedia: false,
|
|
1724
|
+
role: "file_reference",
|
|
1725
|
+
path: asset.sourcePath ?? asset.path,
|
|
1726
|
+
sha256: asset.sourceSha256 ?? asset.sha256,
|
|
1727
|
+
byteLength: asset.sourceByteLength ?? asset.byteLength,
|
|
1728
|
+
mime: asset.sourceMime ?? asset.mime
|
|
1729
|
+
};
|
|
1730
|
+
}));
|
|
1731
|
+
const entries = history.map((item) => ({
|
|
1732
|
+
sender: item.message.senderId,
|
|
1733
|
+
body: item.prepared.body,
|
|
1734
|
+
timestamp: item.message.createdAt ? Date.parse(item.message.createdAt) : void 0,
|
|
1735
|
+
messageId: item.message.id
|
|
1736
|
+
}));
|
|
1737
|
+
const inputBody = entries.length ? buildHistoryContextFromEntries({
|
|
1738
|
+
entries,
|
|
1739
|
+
currentMessage: prepared.body,
|
|
1740
|
+
excludeLast: false,
|
|
1741
|
+
formatEntry: (entry) => `${entry.sender}: ${entry.body}`
|
|
1742
|
+
}) : prepared.body;
|
|
1743
|
+
const attachments = all.flatMap((item) => {
|
|
1744
|
+
const attachment = item.prepared.attachment;
|
|
1745
|
+
return attachment?.status === "ready" && attachment.path ? [{
|
|
1746
|
+
path: attachment.path,
|
|
1747
|
+
filename: attachment.filename,
|
|
1748
|
+
kind: attachment.kind,
|
|
1749
|
+
messageId: item.message.id,
|
|
1750
|
+
sender: item.message.senderId
|
|
1751
|
+
}] : [];
|
|
1752
|
+
});
|
|
1753
|
+
return {
|
|
1754
|
+
inputBody,
|
|
1755
|
+
attachments,
|
|
1756
|
+
assets,
|
|
1757
|
+
history: buildInboundHistoryFromEntries({ entries, limit: entries.length }),
|
|
1758
|
+
mediaPath: selected?.prepared.mediaPath,
|
|
1759
|
+
mediaMime: selected?.prepared.mediaMime
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1571
1763
|
// src/native-monitor.ts
|
|
1572
1764
|
function nativeWasMentioned(message) {
|
|
1573
1765
|
return message.mentions?.state === "known" && message.mentions.self === true;
|
|
@@ -1575,14 +1767,14 @@ function nativeWasMentioned(message) {
|
|
|
1575
1767
|
async function projectNativeRead(client, accountId, state) {
|
|
1576
1768
|
const pending2 = state.items().filter((value) => value.readPending);
|
|
1577
1769
|
const threads = [...new Set(pending2.map((item) => item.message.threadId))];
|
|
1578
|
-
for (const
|
|
1770
|
+
for (const threadId2 of threads) {
|
|
1579
1771
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1580
1772
|
let cursor;
|
|
1581
1773
|
let thread;
|
|
1582
1774
|
const seen = /* @__PURE__ */ new Set();
|
|
1583
1775
|
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
1584
1776
|
const page = await client.threads(accountId, cursor);
|
|
1585
|
-
thread = page.items.find((value) => !isNativeTombstone(value) && value.id ===
|
|
1777
|
+
thread = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
|
|
1586
1778
|
if (thread || !page.nextCursor) break;
|
|
1587
1779
|
if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
|
|
1588
1780
|
seen.add(page.nextCursor);
|
|
@@ -1593,7 +1785,7 @@ async function projectNativeRead(client, accountId, state) {
|
|
|
1593
1785
|
const receipt = await client.markRead(accountId, thread.id, thread.readStateVersion);
|
|
1594
1786
|
const notification = receipt?.phoneNotification?.state;
|
|
1595
1787
|
const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
|
|
1596
|
-
for (const item of pending2.filter((item2) => item2.message.threadId ===
|
|
1788
|
+
for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState });
|
|
1597
1789
|
break;
|
|
1598
1790
|
} catch (error) {
|
|
1599
1791
|
if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
|
|
@@ -1616,8 +1808,20 @@ async function dispatchNativeMessage(params) {
|
|
|
1616
1808
|
const policy = resolveWeChatPolicyContext({ account, cfg, chatId: message.threadId });
|
|
1617
1809
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "agent-wechat" });
|
|
1618
1810
|
const commandBody = normalizeWeChatCommandBody(message.type === 1 ? message.body ?? "" : "", { isGroup, wasMentioned });
|
|
1811
|
+
const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
|
|
1812
|
+
const resolve = core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress;
|
|
1813
|
+
const senderAdmission = (senderId) => resolveWeChatIngress({
|
|
1814
|
+
resolve,
|
|
1815
|
+
cfg,
|
|
1816
|
+
accountId: account.accountId,
|
|
1817
|
+
policy,
|
|
1818
|
+
isGroup,
|
|
1819
|
+
chatId: message.threadId,
|
|
1820
|
+
senderId,
|
|
1821
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
|
|
1822
|
+
});
|
|
1619
1823
|
const ingress = await resolveWeChatIngress({
|
|
1620
|
-
resolve
|
|
1824
|
+
resolve,
|
|
1621
1825
|
cfg,
|
|
1622
1826
|
accountId: account.accountId,
|
|
1623
1827
|
policy,
|
|
@@ -1625,11 +1829,15 @@ async function dispatchNativeMessage(params) {
|
|
|
1625
1829
|
chatId: message.threadId,
|
|
1626
1830
|
senderId: message.senderId,
|
|
1627
1831
|
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => []),
|
|
1628
|
-
command: { allowTextCommands, hasControlCommand
|
|
1832
|
+
command: { allowTextCommands, hasControlCommand },
|
|
1629
1833
|
mention: { wasMentioned, allowTextCommands }
|
|
1630
1834
|
});
|
|
1631
1835
|
const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
|
|
1632
1836
|
if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
|
|
1837
|
+
if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
|
|
1838
|
+
state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
|
|
1839
|
+
return "ignored";
|
|
1840
|
+
}
|
|
1633
1841
|
state.update(message.id, { state: "ignored" });
|
|
1634
1842
|
return "ignored";
|
|
1635
1843
|
}
|
|
@@ -1641,17 +1849,19 @@ async function dispatchNativeMessage(params) {
|
|
|
1641
1849
|
return "needs_review";
|
|
1642
1850
|
}
|
|
1643
1851
|
if (preparedMedia.pending) return "pending";
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1852
|
+
preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
|
|
1853
|
+
const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
|
|
1854
|
+
const admitted = [];
|
|
1855
|
+
for (const item of buffered) {
|
|
1856
|
+
if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
|
|
1857
|
+
}
|
|
1858
|
+
const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
|
|
1859
|
+
await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message: historical, core, cfg }),
|
|
1860
|
+
core,
|
|
1861
|
+
cfg
|
|
1862
|
+
));
|
|
1863
|
+
const context = buildNativeContext(history, message, preparedMedia);
|
|
1864
|
+
const inputBody = context.inputBody;
|
|
1655
1865
|
const route = core.channel.routing.resolveAgentRoute({
|
|
1656
1866
|
cfg,
|
|
1657
1867
|
channel: "agent-wechat",
|
|
@@ -1681,10 +1891,12 @@ async function dispatchNativeMessage(params) {
|
|
|
1681
1891
|
Surface: "agent-wechat",
|
|
1682
1892
|
MessageSid: `wechat-native:${account.native.accountId}:${message.id}`,
|
|
1683
1893
|
WasMentioned: isGroup ? wasMentioned : void 0,
|
|
1894
|
+
Attachments: context.attachments,
|
|
1895
|
+
InboundHistory: context.history,
|
|
1684
1896
|
CommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized(commandBody, cfg) ? ingress.commandAccess.authorized : void 0,
|
|
1685
1897
|
OriginatingChannel: "agent-wechat",
|
|
1686
1898
|
OriginatingTo: `wechat:${message.threadId}`,
|
|
1687
|
-
...
|
|
1899
|
+
...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
|
|
1688
1900
|
});
|
|
1689
1901
|
await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
|
|
1690
1902
|
const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
|
|
@@ -1696,9 +1908,10 @@ async function dispatchNativeMessage(params) {
|
|
|
1696
1908
|
agentId: route.agentId,
|
|
1697
1909
|
channelAccountId: account.accountId,
|
|
1698
1910
|
policy,
|
|
1699
|
-
assets:
|
|
1911
|
+
assets: context.assets,
|
|
1912
|
+
history: admitted
|
|
1700
1913
|
});
|
|
1701
|
-
state.
|
|
1914
|
+
state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
|
|
1702
1915
|
const callback = readPolicy !== "on_inbound" && (message.type === 1 || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
|
|
1703
1916
|
if (callback) trackNativePreparedInput(dispatchManifest, callback);
|
|
1704
1917
|
let failed = false;
|
|
@@ -1903,10 +2116,10 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
|
|
|
1903
2116
|
}
|
|
1904
2117
|
return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
|
|
1905
2118
|
}
|
|
1906
|
-
async function sendNativeText(account,
|
|
2119
|
+
async function sendNativeText(account, threadId2, text, key = randomUUID5()) {
|
|
1907
2120
|
const native = account.native;
|
|
1908
2121
|
const { client } = await ensureNativeHost(native);
|
|
1909
|
-
const send = await submitNativeText(client, native.accountId,
|
|
2122
|
+
const send = await submitNativeText(client, native.accountId, threadId2, text, key);
|
|
1910
2123
|
return `agent-wechat-native:${native.accountId}:${send.id}`;
|
|
1911
2124
|
}
|
|
1912
2125
|
function createNativeLoginTool(account) {
|
|
@@ -2357,6 +2570,236 @@ function registerNativeFacade(api, config) {
|
|
|
2357
2570
|
return handler.stop;
|
|
2358
2571
|
}
|
|
2359
2572
|
|
|
2573
|
+
// src/native-history-tools.ts
|
|
2574
|
+
import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress2 } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
2575
|
+
import { imageResultFromFile } from "openclaw/plugin-sdk/channel-actions";
|
|
2576
|
+
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." } };
|
|
2577
|
+
var names = ["wechat_history", "wechat_search", "wechat_message", "wechat_media"];
|
|
2578
|
+
var archiveNames = ["wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"];
|
|
2579
|
+
var messageIdSchema = { type: "string", minLength: 1, maxLength: 4096 };
|
|
2580
|
+
var qualities = ["best", "original", "standard", "thumbnail", "normal", "encoded"];
|
|
2581
|
+
function object(value) {
|
|
2582
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
|
|
2583
|
+
return value;
|
|
2584
|
+
}
|
|
2585
|
+
function string(value, name, max = 4096) {
|
|
2586
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name}`);
|
|
2587
|
+
return value.trim();
|
|
2588
|
+
}
|
|
2589
|
+
function threadId(value) {
|
|
2590
|
+
return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
|
|
2591
|
+
}
|
|
2592
|
+
function messageResult(message, retrievalTool = "wechat_media") {
|
|
2593
|
+
const kind2 = message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 ? "file" : "unsupported";
|
|
2594
|
+
const body = message.type === 1 ? message.body ?? "" : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
|
|
2595
|
+
return {
|
|
2596
|
+
id: message.id,
|
|
2597
|
+
threadId: message.threadId,
|
|
2598
|
+
serverId: message.serverId,
|
|
2599
|
+
senderId: message.senderId,
|
|
2600
|
+
direction: message.direction,
|
|
2601
|
+
type: message.type,
|
|
2602
|
+
createdAt: message.createdAt,
|
|
2603
|
+
body: body.slice(0, 4e3),
|
|
2604
|
+
bodyTruncated: body.length > 4e3,
|
|
2605
|
+
attachment: message.type !== 1 && kind2 !== "unsupported" ? { kind: kind2, retrievalTool, messageId: message.id } : void 0
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
function createNativeHistoryTools(context, dependencies = {}) {
|
|
2609
|
+
return createLookupTools(context, dependencies, false);
|
|
2610
|
+
}
|
|
2611
|
+
function createNativeArchiveTools(context, dependencies = {}) {
|
|
2612
|
+
return createLookupTools(context, dependencies, true);
|
|
2613
|
+
}
|
|
2614
|
+
function createLookupTools(context, dependencies, archive) {
|
|
2615
|
+
const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
|
|
2616
|
+
if (!currentConfig()) return [];
|
|
2617
|
+
const selected = resolveWeChatAccount(currentConfig());
|
|
2618
|
+
if (!selected?.native || !selected.enabled) return [];
|
|
2619
|
+
if (!archive && context.messageChannel !== "agent-wechat") return [];
|
|
2620
|
+
if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
|
|
2621
|
+
if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
2622
|
+
let currentThread;
|
|
2623
|
+
let requesterSenderId;
|
|
2624
|
+
const target = context.deliveryContext?.channel === "agent-wechat" ? context.deliveryContext.to : void 0;
|
|
2625
|
+
if (!archive) {
|
|
2626
|
+
try {
|
|
2627
|
+
if (context.nativeChannelId) currentThread = threadId(context.nativeChannelId);
|
|
2628
|
+
if (target) {
|
|
2629
|
+
const deliveryThread = threadId(target);
|
|
2630
|
+
if (currentThread && currentThread !== deliveryThread) return [];
|
|
2631
|
+
currentThread ??= deliveryThread;
|
|
2632
|
+
}
|
|
2633
|
+
requesterSenderId = string(context.requesterSenderId, "requester sender ID");
|
|
2634
|
+
} catch {
|
|
2635
|
+
return [];
|
|
2636
|
+
}
|
|
2637
|
+
if (!currentThread) return [];
|
|
2638
|
+
}
|
|
2639
|
+
const selectedId = selected.native.accountId;
|
|
2640
|
+
const tool = (name, action, description, properties, required = []) => ({
|
|
2641
|
+
name,
|
|
2642
|
+
label: name.replaceAll("_", " "),
|
|
2643
|
+
description,
|
|
2644
|
+
// The pinned host's tool_call wrapper JSON-serializes image blocks. Its
|
|
2645
|
+
// public direct-only surface preserves actual image content for vision.
|
|
2646
|
+
...action === "media" ? { catalogMode: "direct-only" } : {},
|
|
2647
|
+
parameters: { type: "object", additionalProperties: false, properties, required },
|
|
2648
|
+
execute: async (_callId, input, signal) => {
|
|
2649
|
+
const args = object(input);
|
|
2650
|
+
const guard = () => {
|
|
2651
|
+
signal?.throwIfAborted();
|
|
2652
|
+
context.assertInvocationCurrent?.();
|
|
2653
|
+
const cfg2 = currentConfig();
|
|
2654
|
+
const account2 = resolveWeChatAccount(cfg2);
|
|
2655
|
+
if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId) throw Error("WeChat account configuration changed; start a new turn");
|
|
2656
|
+
return { cfg: cfg2, account: account2, native: account2.native };
|
|
2657
|
+
};
|
|
2658
|
+
const { cfg, account, native } = guard();
|
|
2659
|
+
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");
|
|
2660
|
+
const limit = args.limit ?? 20;
|
|
2661
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
|
|
2662
|
+
const cursor = args.cursor === void 0 ? void 0 : string(args.cursor, "cursor", 8192);
|
|
2663
|
+
const query = args.query === void 0 ? void 0 : string(args.query, "query", 256);
|
|
2664
|
+
if (query && Buffer.byteLength(query) > 256) throw Error("Search query exceeds 256 UTF-8 bytes");
|
|
2665
|
+
if (action === "search" && !query) throw Error("A non-empty search query is required");
|
|
2666
|
+
const conversation = archive ? args.threadId === void 0 ? void 0 : threadId(args.threadId) : currentThread;
|
|
2667
|
+
if (action === "history" && !conversation) throw Error("A thread ID is required");
|
|
2668
|
+
const messageId = Object.hasOwn(properties, "messageId") ? string(args.messageId, "message ID") : void 0;
|
|
2669
|
+
if (args.download !== void 0 && typeof args.download !== "boolean") throw Error("download must be a boolean");
|
|
2670
|
+
const quality = args.quality ?? "best";
|
|
2671
|
+
if (!qualities.includes(quality)) throw Error("Invalid media quality");
|
|
2672
|
+
const core = dependencies.core ?? getWeChatRuntime();
|
|
2673
|
+
const allowed = async (sender) => {
|
|
2674
|
+
if (archive) return true;
|
|
2675
|
+
const { cfg: cfg2, account: account2 } = guard();
|
|
2676
|
+
const result2 = await resolveWeChatIngress({
|
|
2677
|
+
resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
|
|
2678
|
+
cfg: cfg2,
|
|
2679
|
+
accountId: account2.accountId,
|
|
2680
|
+
policy: resolveWeChatPolicyContext({ account: account2, cfg: cfg2, chatId: conversation }),
|
|
2681
|
+
isGroup: conversation.endsWith("@chatroom"),
|
|
2682
|
+
chatId: conversation,
|
|
2683
|
+
senderId: sender,
|
|
2684
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account2.accountId, env: process.env }).catch(() => [])
|
|
2685
|
+
});
|
|
2686
|
+
return result2.ingress.admission === "dispatch";
|
|
2687
|
+
};
|
|
2688
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
2689
|
+
guard();
|
|
2690
|
+
const { client } = await (dependencies.host ?? ensureNativeHost)(native);
|
|
2691
|
+
guard();
|
|
2692
|
+
const accountId = native.accountId;
|
|
2693
|
+
let result;
|
|
2694
|
+
if (action === "threads") {
|
|
2695
|
+
const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
|
|
2696
|
+
const items = [];
|
|
2697
|
+
for (const thread of page.items) {
|
|
2698
|
+
if (isNativeTombstone(thread)) continue;
|
|
2699
|
+
if (thread.accountId !== accountId) throw Error("Thread response crosses an account boundary");
|
|
2700
|
+
items.push({ id: thread.id, peerId: thread.peerId, kind: thread.kind, displayName: thread.displayName, unreadCount: thread.unreadCount });
|
|
2701
|
+
}
|
|
2702
|
+
result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
|
|
2703
|
+
} else if (action === "message" || action === "media") {
|
|
2704
|
+
const message = await client.message(accountId, messageId);
|
|
2705
|
+
guard();
|
|
2706
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
2707
|
+
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");
|
|
2708
|
+
result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
|
|
2709
|
+
if (action === "media") {
|
|
2710
|
+
let prepared;
|
|
2711
|
+
try {
|
|
2712
|
+
prepared = await prepareNativeAttachment({
|
|
2713
|
+
client,
|
|
2714
|
+
account,
|
|
2715
|
+
message,
|
|
2716
|
+
core,
|
|
2717
|
+
cfg,
|
|
2718
|
+
quality,
|
|
2719
|
+
download: args.download !== false,
|
|
2720
|
+
waitMs: 2e3,
|
|
2721
|
+
signal,
|
|
2722
|
+
assertCurrent: guard,
|
|
2723
|
+
beforeMediaAccess: async () => {
|
|
2724
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
guard();
|
|
2729
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
2730
|
+
const details2 = {
|
|
2731
|
+
messageId: message.id,
|
|
2732
|
+
threadId: message.threadId,
|
|
2733
|
+
status: error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
|
|
2734
|
+
retryable: error.retryable,
|
|
2735
|
+
reason: error.code
|
|
2736
|
+
};
|
|
2737
|
+
return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
|
|
2738
|
+
}
|
|
2739
|
+
guard();
|
|
2740
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
2741
|
+
const details = {
|
|
2742
|
+
messageId: message.id,
|
|
2743
|
+
threadId: message.threadId,
|
|
2744
|
+
status: prepared.pending ? "pending" : prepared.attachment?.status ?? "unsupported",
|
|
2745
|
+
attachment: prepared.attachment,
|
|
2746
|
+
variant: prepared.variant,
|
|
2747
|
+
jobId: prepared.jobId,
|
|
2748
|
+
retryable: prepared.retryable ?? false,
|
|
2749
|
+
reason: prepared.reason,
|
|
2750
|
+
body: prepared.body
|
|
2751
|
+
};
|
|
2752
|
+
const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
|
|
2753
|
+
const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
2754
|
+
guard();
|
|
2755
|
+
return output;
|
|
2756
|
+
}
|
|
2757
|
+
} else {
|
|
2758
|
+
const options = { limit, cursor, ...action === "search" ? { query } : {} };
|
|
2759
|
+
const page = conversation ? await client.messages(accountId, conversation, options) : await client.searchMessages(accountId, options);
|
|
2760
|
+
const items = [];
|
|
2761
|
+
for (const message of page.items) {
|
|
2762
|
+
if (isNativeTombstone(message)) continue;
|
|
2763
|
+
if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
|
|
2764
|
+
if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
|
|
2765
|
+
}
|
|
2766
|
+
result = {
|
|
2767
|
+
...page,
|
|
2768
|
+
items,
|
|
2769
|
+
scope: "local_profile",
|
|
2770
|
+
searchMode: action === "search" ? "case_insensitive_substring" : void 0,
|
|
2771
|
+
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."
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
guard();
|
|
2775
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
2776
|
+
}
|
|
2777
|
+
});
|
|
2778
|
+
const querySchema = { type: "string", minLength: 1, maxLength: 256 };
|
|
2779
|
+
const mediaSchema = {
|
|
2780
|
+
messageId: messageIdSchema,
|
|
2781
|
+
download: { type: "boolean", default: true, description: "Set false to use cached media only without requesting a WeChat transfer." },
|
|
2782
|
+
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." }
|
|
2783
|
+
};
|
|
2784
|
+
if (archive) return [
|
|
2785
|
+
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 }),
|
|
2786
|
+
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"]),
|
|
2787
|
+
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"]),
|
|
2788
|
+
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"]),
|
|
2789
|
+
tool(archiveNames[4], "media", "Retrieve an attachment from any thread in the configured WeChat account by its message ID. 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"])
|
|
2790
|
+
];
|
|
2791
|
+
return [
|
|
2792
|
+
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 }),
|
|
2793
|
+
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"]),
|
|
2794
|
+
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"]),
|
|
2795
|
+
tool(names[3], "media", "Retrieve a message attachment from the current WeChat conversation. 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"])
|
|
2796
|
+
];
|
|
2797
|
+
}
|
|
2798
|
+
function registerNativeHistoryTools(api) {
|
|
2799
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeHistoryTools(context) }, { names });
|
|
2800
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2360
2803
|
// index.ts
|
|
2361
2804
|
var plugin = {
|
|
2362
2805
|
id: "agent-wechat",
|
|
@@ -2367,6 +2810,7 @@ var plugin = {
|
|
|
2367
2810
|
setWeChatRuntime(api.runtime);
|
|
2368
2811
|
const selected = resolveWeChatAccount(api.config);
|
|
2369
2812
|
api.registerChannel({ plugin: wechatPlugin });
|
|
2813
|
+
registerNativeHistoryTools(api);
|
|
2370
2814
|
const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
|
|
2371
2815
|
};
|
|
2372
2816
|
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
|
],
|