@agent-wechat/agent-wechat 1.0.0-beta.1 → 1.0.0-beta.11
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 +104 -7
- package/dist/index.js +1590 -232
- package/openclaw.plugin.json +12 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ var nativeConfigSchema = {
|
|
|
22
22
|
}
|
|
23
23
|
}] },
|
|
24
24
|
consumerStateDir: { type: "string" },
|
|
25
|
-
readPolicy: { type: "string", enum: ["never", "on_model_dispatch"] },
|
|
25
|
+
readPolicy: { type: "string", enum: ["never", "on_inbound", "on_model_dispatch"] },
|
|
26
26
|
gatewayFacade: { type: "object", additionalProperties: false, required: ["enabled"], properties: { enabled: { type: "boolean" }, prefix: { type: "string" } } }
|
|
27
27
|
}
|
|
28
28
|
};
|
|
@@ -110,7 +110,7 @@ import { constants } from "node:fs";
|
|
|
110
110
|
import { access, lstat, readFile, realpath } from "node:fs/promises";
|
|
111
111
|
import { spawn, execFile } from "node:child_process";
|
|
112
112
|
import { promisify } from "node:util";
|
|
113
|
-
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
113
|
+
import { delimiter, dirname, isAbsolute, join, toNamespacedPath } from "node:path";
|
|
114
114
|
import { createConnection } from "node:net";
|
|
115
115
|
|
|
116
116
|
// ../shared/dist/native.generated.js
|
|
@@ -146,13 +146,13 @@ var NativeApiError = class extends Error {
|
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
148
|
function validateNativeBaseUrl(input) {
|
|
149
|
-
const
|
|
150
|
-
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
|
|
151
|
-
if (
|
|
149
|
+
const url2 = new URL(input);
|
|
150
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url2.hostname.toLowerCase());
|
|
151
|
+
if (url2.username || url2.password || url2.search || url2.hash || !["http:", "https:"].includes(url2.protocol) || url2.protocol === "http:" && !loopback)
|
|
152
152
|
throw new Error("Native API requires verified HTTPS or a loopback HTTP endpoint, without embedded credentials");
|
|
153
|
-
if (!
|
|
154
|
-
|
|
155
|
-
return
|
|
153
|
+
if (!url2.pathname.endsWith("/"))
|
|
154
|
+
url2.pathname += "/";
|
|
155
|
+
return url2;
|
|
156
156
|
}
|
|
157
157
|
var id = (value) => {
|
|
158
158
|
if (typeof value !== "string" || !value || value === "." || value === ".." || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value))
|
|
@@ -169,6 +169,7 @@ var NativeWeChatClient = class {
|
|
|
169
169
|
token;
|
|
170
170
|
fetchImpl;
|
|
171
171
|
timeoutMs;
|
|
172
|
+
streamIdleTimeoutMs;
|
|
172
173
|
constructor(options) {
|
|
173
174
|
this.baseUrl = validateNativeBaseUrl(options.baseUrl);
|
|
174
175
|
const nodeEnvironment = globalThis.process?.env;
|
|
@@ -179,6 +180,9 @@ var NativeWeChatClient = class {
|
|
|
179
180
|
this.token = options.token;
|
|
180
181
|
this.fetchImpl = options.fetch ?? fetch;
|
|
181
182
|
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
183
|
+
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 45e3;
|
|
184
|
+
if (!Number.isInteger(this.streamIdleTimeoutMs) || this.streamIdleTimeoutMs < 1 || this.streamIdleTimeoutMs > 3e5)
|
|
185
|
+
throw new Error("Invalid native stream idle timeout");
|
|
182
186
|
}
|
|
183
187
|
async response(path, init = {}, stream = false) {
|
|
184
188
|
const headers = new Headers(init.headers);
|
|
@@ -279,8 +283,24 @@ var NativeWeChatClient = class {
|
|
|
279
283
|
throw new Error("Invalid login idempotency key");
|
|
280
284
|
return this.request(`v1/accounts/${id(accountId)}/auth/login`, { method: "POST", headers: key ? { "Idempotency-Key": key } : void 0, body: JSON.stringify(options) });
|
|
281
285
|
}
|
|
282
|
-
operation(accountId, operationId) {
|
|
283
|
-
return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}
|
|
286
|
+
operation(accountId, operationId, signal) {
|
|
287
|
+
return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { signal });
|
|
288
|
+
}
|
|
289
|
+
/** Submit once with a stable key; inspect the returned operation after uncertainty. */
|
|
290
|
+
async recallMessage(accountId, messageId, serverId, idempotencyKey, signal) {
|
|
291
|
+
if (typeof serverId !== "string" || !/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n)
|
|
292
|
+
throw new Error("Invalid exact recall server ID");
|
|
293
|
+
if (typeof idempotencyKey !== "string" || !idempotencyKey || idempotencyKey.length > 200 || /[\r\n]/.test(idempotencyKey))
|
|
294
|
+
throw new Error("Invalid recall idempotency key");
|
|
295
|
+
const operation = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/recall`, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
298
|
+
body: JSON.stringify({ serverId }),
|
|
299
|
+
signal
|
|
300
|
+
});
|
|
301
|
+
if (!operation || operation.accountId !== accountId || operation.kind !== "message_recall" || typeof operation.id !== "string" || !operation.id || typeof operation.state !== "string")
|
|
302
|
+
throw new Error("Native recall operation identity is invalid");
|
|
303
|
+
return operation;
|
|
284
304
|
}
|
|
285
305
|
cancelOperation(accountId, operationId) {
|
|
286
306
|
return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { method: "DELETE" });
|
|
@@ -318,8 +338,8 @@ var NativeWeChatClient = class {
|
|
|
318
338
|
groups(accountId, options) {
|
|
319
339
|
return this.request(`v1/accounts/${id(accountId)}/groups${listQuery(options)}`);
|
|
320
340
|
}
|
|
321
|
-
thread(accountId,
|
|
322
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
341
|
+
thread(accountId, threadId2) {
|
|
342
|
+
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}`);
|
|
323
343
|
}
|
|
324
344
|
contact(accountId, username) {
|
|
325
345
|
return this.request(`v1/accounts/${id(accountId)}/contacts/${id(username)}`);
|
|
@@ -357,12 +377,12 @@ var NativeWeChatClient = class {
|
|
|
357
377
|
syncGroups(accountId) {
|
|
358
378
|
return this.request(`v1/accounts/${id(accountId)}/group-sync`, { method: "POST", body: "{}" });
|
|
359
379
|
}
|
|
360
|
-
async messages(accountId,
|
|
361
|
-
const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
380
|
+
async messages(accountId, threadId2, options) {
|
|
381
|
+
const result = await this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/messages${listQuery(options)}`);
|
|
362
382
|
for (const msg of result.items) {
|
|
363
383
|
if (isNativeTombstone(msg))
|
|
364
384
|
continue;
|
|
365
|
-
if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !==
|
|
385
|
+
if (typeof msg.id !== "string" || msg.accountId !== accountId || msg.threadId !== threadId2 || msg.serverId != null && (typeof msg.serverId !== "string" || !/^\d+$/.test(msg.serverId)))
|
|
366
386
|
throw new Error("Invalid native message identity");
|
|
367
387
|
}
|
|
368
388
|
return result;
|
|
@@ -373,14 +393,37 @@ var NativeWeChatClient = class {
|
|
|
373
393
|
throw new Error("Invalid native message identity");
|
|
374
394
|
return message;
|
|
375
395
|
}
|
|
376
|
-
|
|
396
|
+
/** Resolve only the stored original in this message's own conversation. */
|
|
397
|
+
async quotedMessage(accountId, messageId) {
|
|
398
|
+
const quote = await this.request(`v1/accounts/${id(accountId)}/messages/${id(messageId)}/quote`);
|
|
399
|
+
if (quote.accountId !== accountId || quote.messageId !== messageId || typeof quote.threadId !== "string" || !["resolved", "embedded", "missing", "invalid", "unsupported", "ambiguous"].includes(quote.state) || (quote.state === "resolved" ? !quote.message || quote.message.accountId !== accountId || quote.message.threadId !== quote.threadId || quote.message.id === messageId : quote.message != null))
|
|
400
|
+
throw new Error("Invalid native quote identity");
|
|
401
|
+
return quote;
|
|
402
|
+
}
|
|
403
|
+
/** Account-wide local search, with the daemon's query-bound opaque cursor. */
|
|
404
|
+
async searchMessages(accountId, options) {
|
|
405
|
+
const result = await this.request(`v1/accounts/${id(accountId)}/messages${listQuery(options)}`);
|
|
406
|
+
for (const message of result.items) {
|
|
407
|
+
if (isNativeTombstone(message))
|
|
408
|
+
continue;
|
|
409
|
+
if (typeof message.id !== "string" || message.accountId !== accountId || typeof message.threadId !== "string" || !message.threadId || message.serverId != null && (typeof message.serverId !== "string" || !/^\d+$/.test(message.serverId)))
|
|
410
|
+
throw new Error("Invalid native archive message identity");
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
sendText(accountId, threadId2, text3, idempotencyKey) {
|
|
377
415
|
if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
|
|
378
416
|
throw new Error("An idempotency key is required");
|
|
379
|
-
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId, text }) });
|
|
417
|
+
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text: text3 }) });
|
|
380
418
|
}
|
|
381
419
|
send(accountId, sendId, signal) {
|
|
382
420
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
|
|
383
421
|
}
|
|
422
|
+
sendShare(accountId, input, key, signal) {
|
|
423
|
+
if (!key || key.length > 200 || /[\u0000-\u001f\u007f]/.test(key))
|
|
424
|
+
throw new Error("An idempotency key is required");
|
|
425
|
+
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(input), signal });
|
|
426
|
+
}
|
|
384
427
|
sendByKey(accountId, key) {
|
|
385
428
|
if (!key || key.length > 200)
|
|
386
429
|
throw new Error("Invalid idempotency key");
|
|
@@ -389,11 +432,11 @@ var NativeWeChatClient = class {
|
|
|
389
432
|
cancelSend(accountId, sendId) {
|
|
390
433
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
|
|
391
434
|
}
|
|
392
|
-
async upload(accountId, bytes,
|
|
393
|
-
if (!bytes.length || bytes.length > NATIVE_MAX_SOURCE_BYTES || !
|
|
435
|
+
async upload(accountId, bytes, name2, contentType = "application/octet-stream", signal) {
|
|
436
|
+
if (!bytes.length || bytes.length > NATIVE_MAX_SOURCE_BYTES || !name2 || name2.length > 255 || /[\r\n/\\]/.test(name2))
|
|
394
437
|
throw new Error("Invalid bounded media upload");
|
|
395
438
|
const form = new FormData();
|
|
396
|
-
form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }),
|
|
439
|
+
form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }), name2);
|
|
397
440
|
return this.request(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal });
|
|
398
441
|
}
|
|
399
442
|
sendMedia(accountId, input, key) {
|
|
@@ -426,26 +469,21 @@ var NativeWeChatClient = class {
|
|
|
426
469
|
}
|
|
427
470
|
async mediaContent(accountId, assetId, options = {}) {
|
|
428
471
|
const max = options.maxBytes ?? 128 * 1024 * 1024;
|
|
429
|
-
if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range))
|
|
472
|
+
if (!Number.isInteger(max) || max < 1 || max > 128 * 1024 * 1024 || options.range && !/^bytes=(?:\d+-\d*|-\d+)$/.test(options.range) || options.representation != null && !["original", "preview"].includes(options.representation))
|
|
430
473
|
throw new Error("Invalid bounded content request");
|
|
431
|
-
const
|
|
474
|
+
const query = options.representation === "preview" ? "?representation=preview" : "";
|
|
475
|
+
const response = await this.response(`v1/accounts/${id(accountId)}/media/${id(assetId)}/content${query}`, { signal: options.signal, headers: options.range ? { Range: options.range } : {} });
|
|
432
476
|
const length = response.headers.get("content-length");
|
|
433
477
|
if (length && (!/^\d+$/.test(length) || Number(length) > max)) {
|
|
434
478
|
await response.body?.cancel();
|
|
435
479
|
throw new Error("Native media response exceeds its bound");
|
|
436
480
|
}
|
|
437
|
-
return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), status: response.status };
|
|
481
|
+
return { bytes: await this.readBounded(response, max), contentType: response.headers.get("content-type") ?? "application/octet-stream", etag: response.headers.get("etag"), contentRange: response.headers.get("content-range"), sourceSha256: response.headers.get("x-source-sha256"), status: response.status };
|
|
438
482
|
}
|
|
439
|
-
markRead(accountId,
|
|
440
|
-
if (
|
|
441
|
-
throw new Error("Invalid exact-ID read projection");
|
|
442
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId)}/read-state`, { method: "POST", body: JSON.stringify({ messageIds: [...new Set(messageIds)], expectedVersion }) });
|
|
443
|
-
}
|
|
444
|
-
markReadThrough(accountId, threadId, throughMessageId, expectedVersion) {
|
|
445
|
-
id(throughMessageId);
|
|
446
|
-
if (typeof expectedVersion !== "string" || !expectedVersion)
|
|
483
|
+
markRead(accountId, threadId2, expectedVersion) {
|
|
484
|
+
if (typeof expectedVersion !== "string" || !expectedVersion || expectedVersion.length > 128)
|
|
447
485
|
throw new Error("Read version is required");
|
|
448
|
-
return this.request(`v1/accounts/${id(accountId)}/threads/${id(
|
|
486
|
+
return this.request(`v1/accounts/${id(accountId)}/threads/${id(threadId2)}/read-state`, { method: "POST", body: JSON.stringify({ expectedVersion }) });
|
|
449
487
|
}
|
|
450
488
|
events(accountId, after = "0", limit = 100) {
|
|
451
489
|
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
@@ -456,12 +494,27 @@ var NativeWeChatClient = class {
|
|
|
456
494
|
return this.request(`v1/accounts/${id(accountId)}/consumers/${id(consumerId)}/ack`, { method: "POST", body: JSON.stringify({ cursor: decimal(cursor) }) });
|
|
457
495
|
}
|
|
458
496
|
async *watch(accountId, after = "0", signal) {
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
497
|
+
const connection = new AbortController();
|
|
498
|
+
const streamSignal = signal ? AbortSignal.any([signal, connection.signal]) : connection.signal;
|
|
499
|
+
const bounded = async (work, milliseconds, reason) => {
|
|
500
|
+
if (streamSignal.aborted)
|
|
501
|
+
throw streamSignal.reason;
|
|
502
|
+
let timer;
|
|
503
|
+
let onAbort = () => {
|
|
504
|
+
};
|
|
505
|
+
const interrupted = new Promise((_resolve, reject) => {
|
|
506
|
+
onAbort = () => reject(streamSignal.reason ?? new Error("Native SSE cancelled"));
|
|
507
|
+
streamSignal.addEventListener("abort", onAbort, { once: true });
|
|
508
|
+
timer = setTimeout(() => connection.abort(new Error(reason)), milliseconds);
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
return await Promise.race([work(), interrupted]);
|
|
512
|
+
} finally {
|
|
513
|
+
clearTimeout(timer);
|
|
514
|
+
streamSignal.removeEventListener("abort", onAbort);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
let reader;
|
|
465
518
|
const decoder = new TextDecoder();
|
|
466
519
|
let buffer = "";
|
|
467
520
|
let data = [];
|
|
@@ -469,8 +522,17 @@ var NativeWeChatClient = class {
|
|
|
469
522
|
let eventCursor = "";
|
|
470
523
|
let eventType = "";
|
|
471
524
|
try {
|
|
525
|
+
const response = await bounded(() => this.response(`v1/accounts/${id(accountId)}/events/stream?after=${decimal(after)}`, { signal: streamSignal, headers: { Accept: "text/event-stream" } }, true), this.timeoutMs, "Native SSE connection timeout");
|
|
526
|
+
if (!response.headers.get("content-type")?.startsWith("text/event-stream")) {
|
|
527
|
+
await response.body?.cancel();
|
|
528
|
+
throw new Error("Expected native SSE response");
|
|
529
|
+
}
|
|
530
|
+
reader = response.body?.getReader();
|
|
531
|
+
if (!reader)
|
|
532
|
+
throw new Error("Native SSE has no body");
|
|
472
533
|
while (true) {
|
|
473
|
-
const
|
|
534
|
+
const activeReader = reader;
|
|
535
|
+
const { value, done } = await bounded(() => activeReader.read(), this.streamIdleTimeoutMs, "Native SSE idle timeout");
|
|
474
536
|
if (done)
|
|
475
537
|
break;
|
|
476
538
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -510,9 +572,12 @@ var NativeWeChatClient = class {
|
|
|
510
572
|
}
|
|
511
573
|
}
|
|
512
574
|
} finally {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
575
|
+
connection.abort();
|
|
576
|
+
if (reader) {
|
|
577
|
+
await reader.cancel().catch(() => {
|
|
578
|
+
});
|
|
579
|
+
reader.releaseLock();
|
|
580
|
+
}
|
|
516
581
|
}
|
|
517
582
|
}
|
|
518
583
|
};
|
|
@@ -533,11 +598,22 @@ function requireNativeIdentity(value) {
|
|
|
533
598
|
}
|
|
534
599
|
}
|
|
535
600
|
function nativeChildEnvironment(token, environment = process.env) {
|
|
536
|
-
return Object.fromEntries(["PATH", "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR"].filter((
|
|
601
|
+
return Object.fromEntries(["PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "WINDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR"].filter((name2) => environment[name2] !== void 0 && environment[name2] !== token).map((name2) => [name2, environment[name2]]));
|
|
537
602
|
}
|
|
538
603
|
function nativeHostTarget(platform = process.platform, arch = process.arch) {
|
|
539
|
-
|
|
540
|
-
|
|
604
|
+
const cpu = arch === "arm64" ? "aarch64" : "x86_64";
|
|
605
|
+
const suffix = { linux: "unknown-linux-gnu", darwin: "apple-darwin", win32: "pc-windows-msvc" }[platform];
|
|
606
|
+
if (!suffix || !["x64", "arm64"].includes(arch) || platform === "darwin" && arch !== "arm64") throw new Error("native_binary_incompatible: unsupported host platform; use external mode for remote daemons");
|
|
607
|
+
return { platform, arch, rustTarget: `${cpu}-${suffix}`, key: `${platform}-${arch}${platform === "linux" ? "-gnu" : ""}`, executable: platform === "win32" ? "wx.exe" : "wx" };
|
|
608
|
+
}
|
|
609
|
+
function nativeExecutableFormat(bytes, platform = process.platform) {
|
|
610
|
+
if (platform === "linux") return bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70]));
|
|
611
|
+
if (platform === "darwin") return bytes.subarray(0, 4).equals(Buffer.from([207, 250, 237, 254]));
|
|
612
|
+
if (platform === "win32" && bytes.length >= 64 && bytes[0] === 77 && bytes[1] === 90) {
|
|
613
|
+
const offset = bytes.readUInt32LE(60);
|
|
614
|
+
return offset >= 64 && offset <= bytes.length - 4 && bytes.subarray(offset, offset + 4).equals(Buffer.from([80, 69, 0, 0]));
|
|
615
|
+
}
|
|
616
|
+
return false;
|
|
541
617
|
}
|
|
542
618
|
async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "", environment = nativeChildEnvironment("")) {
|
|
543
619
|
const target = nativeHostTarget();
|
|
@@ -546,40 +622,48 @@ async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "",
|
|
|
546
622
|
if (!candidate) {
|
|
547
623
|
for (const directory of path.split(delimiter).filter(isAbsolute)) {
|
|
548
624
|
try {
|
|
549
|
-
await access(join(directory,
|
|
550
|
-
candidate = join(directory,
|
|
625
|
+
await access(join(directory, target.executable), constants.X_OK);
|
|
626
|
+
candidate = join(directory, target.executable);
|
|
551
627
|
break;
|
|
552
628
|
} catch {
|
|
553
629
|
}
|
|
630
|
+
if (process.platform === "win32") {
|
|
631
|
+
const payload = join(directory, "node_modules", "@agent-wechat", "cli", "scripts", "native-launcher.mjs");
|
|
632
|
+
try {
|
|
633
|
+
await access(payload);
|
|
634
|
+
candidate = payload;
|
|
635
|
+
break;
|
|
636
|
+
} catch {
|
|
637
|
+
}
|
|
638
|
+
}
|
|
554
639
|
}
|
|
555
640
|
}
|
|
556
641
|
if (!candidate) throw new Error("native_binary_missing: install native wx separately or configure its absolute path");
|
|
557
642
|
candidate = await realpath(candidate);
|
|
558
|
-
if (candidate.endsWith("/scripts/native-launcher.mjs")) {
|
|
643
|
+
if (candidate.replaceAll("\\", "/").endsWith("/scripts/native-launcher.mjs")) {
|
|
559
644
|
const root = dirname(dirname(candidate));
|
|
560
645
|
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
561
646
|
if (pkg.name !== "@agent-wechat/cli" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)) throw new Error("Unsupported native CLI launcher");
|
|
562
647
|
const manifest = JSON.parse(await readFile(join(root, "native-release.json"), "utf8"));
|
|
563
|
-
const asset = manifest.assets?.find((value) => value.rustTarget === target.rustTarget && value.platform ===
|
|
648
|
+
const asset = manifest.assets?.find((value) => value.rustTarget === target.rustTarget && value.platform === target.platform && value.arch === target.arch && (target.platform !== "linux" || value.libc === "gnu"));
|
|
564
649
|
if (manifest.version !== pkg.version || !asset || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("Native CLI manifest is incompatible");
|
|
565
|
-
candidate = await realpath(join(root, "native", pkg.version,
|
|
650
|
+
candidate = await realpath(join(root, "native", pkg.version, target.key, target.executable));
|
|
566
651
|
const bytes = await readFile(candidate);
|
|
567
652
|
if (bytes.length !== asset.bytes || createHash2("sha256").update(bytes).digest("hex") !== asset.sha256) throw new Error("Installed native executable checksum mismatch");
|
|
568
653
|
}
|
|
569
654
|
const stat = await lstat(candidate);
|
|
570
655
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Native executable must be a regular file");
|
|
571
656
|
await access(candidate, constants.X_OK);
|
|
572
|
-
|
|
573
|
-
if (!magic.equals(Buffer.from([127, 69, 76, 70]))) throw new Error("native_binary_required: configure the actual native ELF executable, not an unverified process wrapper");
|
|
657
|
+
if (!nativeExecutableFormat(await readFile(candidate))) throw new Error("native_binary_required: configure the actual native executable for this OS");
|
|
574
658
|
const { stdout } = await exec(candidate, ["--json", "version"], { env: environment, timeout: 5e3, maxBuffer: 65536 });
|
|
575
659
|
const version = JSON.parse(stdout);
|
|
576
660
|
requireNativeIdentity(version);
|
|
577
661
|
if (version.target !== target.rustTarget) throw new Error("native_binary_incompatible: executable architecture differs from this host");
|
|
578
662
|
return candidate;
|
|
579
663
|
}
|
|
580
|
-
async function portOccupied(
|
|
664
|
+
async function portOccupied(url2) {
|
|
581
665
|
return new Promise((resolve, reject) => {
|
|
582
|
-
const socket = createConnection({ host:
|
|
666
|
+
const socket = createConnection({ host: url2.hostname.replace(/^\[|\]$/g, ""), port: Number(url2.port || (url2.protocol === "https:" ? 443 : 80)) });
|
|
583
667
|
socket.setTimeout(1e3);
|
|
584
668
|
socket.once("connect", () => {
|
|
585
669
|
socket.destroy();
|
|
@@ -596,7 +680,7 @@ async function portOccupied(url) {
|
|
|
596
680
|
});
|
|
597
681
|
});
|
|
598
682
|
}
|
|
599
|
-
async function stopOwnedChild(child) {
|
|
683
|
+
async function stopOwnedChild(child, graceful) {
|
|
600
684
|
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
|
|
601
685
|
await new Promise((resolve) => {
|
|
602
686
|
const kill = setTimeout(() => {
|
|
@@ -606,7 +690,10 @@ async function stopOwnedChild(child) {
|
|
|
606
690
|
clearTimeout(kill);
|
|
607
691
|
resolve();
|
|
608
692
|
});
|
|
609
|
-
|
|
693
|
+
if (graceful) void graceful().catch(() => {
|
|
694
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
695
|
+
});
|
|
696
|
+
else child.kill("SIGTERM");
|
|
610
697
|
});
|
|
611
698
|
}
|
|
612
699
|
async function startNativeHost(config) {
|
|
@@ -621,11 +708,11 @@ async function startNativeHost(config) {
|
|
|
621
708
|
});
|
|
622
709
|
}
|
|
623
710
|
if (config.mode !== "managed" || config.endpoint || !config.dataDir || !isAbsolute(config.dataDir) || config.serverConfig && !isAbsolute(config.serverConfig)) throw new Error("Managed native mode requires absolute dataDir/config paths and no remote endpoint");
|
|
624
|
-
const
|
|
625
|
-
if (await portOccupied(
|
|
711
|
+
const url2 = validateNativeBaseUrl(`http://${config.listen ?? "127.0.0.1:6174"}`);
|
|
712
|
+
if (await portOccupied(url2)) throw new Error("native_owner_conflict: configured port is already owned; use external mode for an independently operated daemon");
|
|
626
713
|
const executable = await resolveNativeExecutable(config.executable, void 0, nativeChildEnvironment(token));
|
|
627
|
-
const client = new NativeWeChatClient({ baseUrl:
|
|
628
|
-
const args = ["serve", "--data-dir", config.dataDir, "--token-stdin", "--listen",
|
|
714
|
+
const client = new NativeWeChatClient({ baseUrl: url2.href, token, timeoutMs: 1e3 });
|
|
715
|
+
const args = ["serve", "--data-dir", config.dataDir, "--token-stdin", "--listen", url2.host];
|
|
629
716
|
if (config.serverConfig) args.push("--server-config", config.serverConfig);
|
|
630
717
|
const child = spawn(executable, args, { env: nativeChildEnvironment(token), stdio: ["pipe", "ignore", "pipe"], shell: false });
|
|
631
718
|
child.stderr?.on("data", () => {
|
|
@@ -648,10 +735,21 @@ async function startNativeHost(config) {
|
|
|
648
735
|
if (exited) throw new Error("Native owned child exited before readiness");
|
|
649
736
|
try {
|
|
650
737
|
const status = await client.status();
|
|
651
|
-
const
|
|
738
|
+
const canonical = await realpath(config.dataDir);
|
|
739
|
+
const dataDir = process.platform === "win32" ? toNamespacedPath(canonical) : canonical;
|
|
652
740
|
if (exited || status.version.backend !== "native" || status.version.apiVersion !== 1 || status.pid !== child.pid || status.dataDir !== dataDir || typeof status.serverId !== "string" || typeof status.ownershipGeneration !== "string") throw new Error("native_owner_conflict: readiness identity does not match the owned process/store");
|
|
653
741
|
requireNativeIdentity(status.version);
|
|
654
|
-
return host(new NativeWeChatClient({ baseUrl:
|
|
742
|
+
return host(new NativeWeChatClient({ baseUrl: url2.href, token }), token, () => stopOwnedChild(child, async () => {
|
|
743
|
+
const response = await fetch(new URL("v1/daemon/shutdown", url2), {
|
|
744
|
+
method: "POST",
|
|
745
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
746
|
+
body: JSON.stringify({ pid: status.pid, serverId: status.serverId, ownershipGeneration: status.ownershipGeneration }),
|
|
747
|
+
redirect: "error",
|
|
748
|
+
signal: AbortSignal.timeout(3e3)
|
|
749
|
+
});
|
|
750
|
+
if (!response.ok) throw new Error("Owned daemon refused graceful shutdown");
|
|
751
|
+
await response.body?.cancel();
|
|
752
|
+
}));
|
|
655
753
|
} catch (error) {
|
|
656
754
|
if (String(error).includes("native_owner_conflict")) throw error;
|
|
657
755
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
@@ -709,7 +807,7 @@ function collectWeChatStatusIssues(accounts) {
|
|
|
709
807
|
accountId: snapshot.accountId,
|
|
710
808
|
kind: "runtime",
|
|
711
809
|
message: "Prepared model handoff was not confirmed by this OpenClaw host; automatic read state remains unchanged.",
|
|
712
|
-
fix: "
|
|
810
|
+
fix: "Select native.readPolicy=on_inbound to mark successfully handled conversations read without model hooks, or use a compatible prepared-run SDK hook."
|
|
713
811
|
});
|
|
714
812
|
}
|
|
715
813
|
if ((snapshot.needsReview ?? 0) > 0) {
|
|
@@ -980,67 +1078,178 @@ function appendReference(body, reference) {
|
|
|
980
1078
|
return body ? `${body}
|
|
981
1079
|
${reference}` : reference;
|
|
982
1080
|
}
|
|
983
|
-
function safeDisplayName(
|
|
984
|
-
return
|
|
1081
|
+
function safeDisplayName(name2) {
|
|
1082
|
+
return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
|
|
985
1083
|
}
|
|
986
1084
|
function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
|
|
987
1085
|
if (!attachment) return originalBody;
|
|
988
|
-
const
|
|
1086
|
+
const name2 = safeDisplayName(attachment.filename);
|
|
989
1087
|
if (attachment.status !== "ready" || !attachment.path) {
|
|
990
1088
|
const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
|
|
991
1089
|
if (attachment.status === "pending") {
|
|
992
|
-
return appendReference(originalBody, `[${label} pending: ${
|
|
1090
|
+
return appendReference(originalBody, `[${label} pending: ${name2}]`);
|
|
993
1091
|
}
|
|
994
1092
|
if (attachment.status === "expired") {
|
|
995
|
-
return appendReference(originalBody, `[${label} expired: ${
|
|
1093
|
+
return appendReference(originalBody, `[${label} expired: ${name2}; no cached copy available. Ask the sender to resend.]`);
|
|
996
1094
|
}
|
|
997
|
-
return appendReference(originalBody, `[${label} unavailable: ${
|
|
1095
|
+
return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
|
|
998
1096
|
}
|
|
999
1097
|
if (attachment.kind === "file") {
|
|
1000
|
-
const prefix = originalBody.trim() ===
|
|
1001
|
-
return appendReference(prefix, `[File: ${
|
|
1002
|
-
[Local file:
|
|
1098
|
+
const prefix = originalBody.trim() === name2 ? "" : originalBody;
|
|
1099
|
+
return appendReference(prefix, `[File: ${name2}]
|
|
1100
|
+
[Local file: <${attachment.path}>]`);
|
|
1003
1101
|
}
|
|
1004
1102
|
if (exposeNonVisualPath) {
|
|
1005
1103
|
if (attachment.kind === "audio") {
|
|
1006
|
-
return appendReference(originalBody || "<media:audio>", `[Voice message file:
|
|
1104
|
+
return appendReference(originalBody || "<media:audio>", `[Voice message file: <${attachment.path}>]`);
|
|
1007
1105
|
}
|
|
1008
1106
|
if (attachment.kind === "video") {
|
|
1009
|
-
return appendReference(originalBody || "<media:video>", `[Video file:
|
|
1107
|
+
return appendReference(originalBody || "<media:video>", `[Video file: <${attachment.path}>]`);
|
|
1010
1108
|
}
|
|
1011
1109
|
}
|
|
1012
|
-
|
|
1110
|
+
let body = originalBody || (attachment.kind === "image" ? "<media:image>" : originalBody);
|
|
1111
|
+
if (attachment.kind === "image") body = appendReference(body, `[Image file: <${attachment.path}>]`);
|
|
1013
1112
|
if (attachment.kind === "image" && attachment.quality === "thumbnail") {
|
|
1014
1113
|
return appendReference(body, "[Image preview only: thumbnail quality]");
|
|
1015
1114
|
}
|
|
1016
1115
|
return body;
|
|
1017
1116
|
}
|
|
1018
1117
|
|
|
1118
|
+
// src/native-send.ts
|
|
1119
|
+
async function nativeOutboundDelivery(client, accountId, threadId2, messageIds, source) {
|
|
1120
|
+
const receipts = [];
|
|
1121
|
+
try {
|
|
1122
|
+
if (!Array.isArray(messageIds) || !messageIds.length || messageIds.length > 256 || messageIds.some((id2) => typeof id2 !== "string" || !id2 || id2.length > 4096) || new Set(messageIds).size !== messageIds.length) throw Error("Message receipts unavailable");
|
|
1123
|
+
for (const [index, messageId] of messageIds.entries()) {
|
|
1124
|
+
const message = await client.message(accountId, messageId);
|
|
1125
|
+
if (message.id !== messageId || message.accountId !== accountId || message.threadId !== threadId2 || message.direction !== "outgoing" || typeof message.serverId !== "string" || !/^[1-9]\d{0,19}$/.test(message.serverId) || BigInt(message.serverId) > 18446744073709551615n || source.serverIds?.[index] != null && source.serverIds[index] !== message.serverId) throw Error("Message receipt identity mismatch");
|
|
1126
|
+
receipts.push({ messageId, serverId: message.serverId, accountId, threadId: threadId2 });
|
|
1127
|
+
}
|
|
1128
|
+
} catch {
|
|
1129
|
+
return { messageId: "", meta: {
|
|
1130
|
+
accountId,
|
|
1131
|
+
threadId: threadId2,
|
|
1132
|
+
...source,
|
|
1133
|
+
messageIds: receipts,
|
|
1134
|
+
outcome: "unknown",
|
|
1135
|
+
receiptState: "needs_review",
|
|
1136
|
+
retryable: false,
|
|
1137
|
+
note: "Send submitted. Inspect the durable send/job and retained receipts; do not resend automatically."
|
|
1138
|
+
} };
|
|
1139
|
+
}
|
|
1140
|
+
return { messageId: receipts[0].messageId, meta: {
|
|
1141
|
+
accountId,
|
|
1142
|
+
threadId: threadId2,
|
|
1143
|
+
serverId: receipts[0].serverId,
|
|
1144
|
+
outcome: "sent",
|
|
1145
|
+
...source,
|
|
1146
|
+
messageIds: receipts,
|
|
1147
|
+
receiptState: "verified"
|
|
1148
|
+
} };
|
|
1149
|
+
}
|
|
1150
|
+
async function submitNativeText(client, accountId, threadId2, text3, key, options = {}) {
|
|
1151
|
+
let intent = await client.sendText(accountId, threadId2, text3, key);
|
|
1152
|
+
const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
|
|
1153
|
+
let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
|
|
1154
|
+
while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
|
|
1155
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
|
|
1156
|
+
const remaining = deadline - Date.now();
|
|
1157
|
+
if (remaining <= 0) break;
|
|
1158
|
+
intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
|
|
1159
|
+
delay = Math.min(delay * 2, 1e3);
|
|
1160
|
+
}
|
|
1161
|
+
if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
|
|
1162
|
+
return intent;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/native-recall-state.ts
|
|
1166
|
+
function nativeRecallInfo(message) {
|
|
1167
|
+
return message.recall;
|
|
1168
|
+
}
|
|
1169
|
+
function nativeRecallIdentity(threadId2, serverId) {
|
|
1170
|
+
return JSON.stringify([threadId2, serverId]);
|
|
1171
|
+
}
|
|
1172
|
+
function validateNativeRecall(value) {
|
|
1173
|
+
if (!value || typeof value.threadId !== "string" || !value.threadId || value.threadId.length > 4096 || typeof value.serverId !== "string" || !/^[1-9]\d{0,19}$/.test(value.serverId) || BigInt(value.serverId) > 18446744073709551615n || typeof value.observedAt !== "string" || value.observedAt.length > 128 || !Number.isFinite(Date.parse(value.observedAt)) || value.recalledAt !== void 0 && (typeof value.recalledAt !== "string" || value.recalledAt.length > 128 || !Number.isFinite(Date.parse(value.recalledAt))) || value.messageId !== void 0 && (typeof value.messageId !== "string" || !value.messageId || value.messageId.length > 4096)) throw Error("Invalid native recall observation; cursor retained");
|
|
1174
|
+
}
|
|
1175
|
+
function recalledNativeMessage(message, recall) {
|
|
1176
|
+
return {
|
|
1177
|
+
...message,
|
|
1178
|
+
type: 1e4,
|
|
1179
|
+
body: "[Message recalled]",
|
|
1180
|
+
quote: void 0,
|
|
1181
|
+
link: void 0,
|
|
1182
|
+
media: [],
|
|
1183
|
+
mentions: { state: "unknown", userIds: [], self: null, all: null, source: null },
|
|
1184
|
+
recall: { observedAt: recall.observedAt, ...recall.recalledAt ? { recalledAt: recall.recalledAt } : {} }
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1019
1188
|
// src/native-media.ts
|
|
1020
1189
|
function kind(type) {
|
|
1021
1190
|
return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
|
|
1022
1191
|
}
|
|
1023
|
-
var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
|
|
1024
|
-
|
|
1192
|
+
var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "partial", "cancelled", "expired", "needs_review"]);
|
|
1193
|
+
var successfulSendJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded"]);
|
|
1194
|
+
var terminalIdentityErrors = /* @__PURE__ */ new Set(["media_identity_unavailable", "media_source_changed"]);
|
|
1195
|
+
function mediaSendResult(job) {
|
|
1196
|
+
return job.result && typeof job.result === "object" && !Array.isArray(job.result) ? job.result : {};
|
|
1197
|
+
}
|
|
1198
|
+
function mediaSendReceiptIds(job, audioAsVoice) {
|
|
1199
|
+
const result = mediaSendResult(job), ids = result.messageIds;
|
|
1200
|
+
if (!successfulSendJobs.has(job.state) || result.profileProjection === "unknown" || !Array.isArray(ids) || !ids.length || ids.length > 256 || ids.some((id2) => typeof id2 !== "string" || !id2 || id2.length > 4096) || new Set(ids).size !== ids.length) return void 0;
|
|
1201
|
+
if (audioAsVoice) {
|
|
1202
|
+
const chunks = result.chunks;
|
|
1203
|
+
if (!Array.isArray(chunks) || !chunks.length || chunks.length !== ids.length || chunks.some((chunk) => !chunk || chunk.state !== "accepted" || typeof chunk.serverMessageId !== "string")) return void 0;
|
|
1204
|
+
} else if (ids.length !== 1) return void 0;
|
|
1205
|
+
return ids;
|
|
1206
|
+
}
|
|
1207
|
+
async function waitNativeMediaSendReceipts(client, accountId, threadId2, initial, audioAsVoice, timeoutMs = 1e4) {
|
|
1208
|
+
if (initial.accountId !== accountId || initial.threadId !== threadId2 || initial.kind !== "send") throw Error("Native media receipt job identity mismatch; inspect the submitted job and do not resend");
|
|
1209
|
+
const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 1e4));
|
|
1210
|
+
let job = initial, delay = 100;
|
|
1211
|
+
while (successfulSendJobs.has(job.state) && !mediaSendReceiptIds(job, audioAsVoice) && Date.now() < deadline) {
|
|
1212
|
+
const result = mediaSendResult(job);
|
|
1213
|
+
if (["unknown", "not_applicable"].includes(String(result.profileProjection)) || audioAsVoice && Array.isArray(result.chunks) && result.chunks.some((chunk) => !chunk || chunk.state !== "accepted")) break;
|
|
1214
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
|
|
1215
|
+
const remaining = deadline - Date.now();
|
|
1216
|
+
if (remaining <= 0) break;
|
|
1217
|
+
let next;
|
|
1218
|
+
try {
|
|
1219
|
+
next = await client.job(accountId, initial.id, AbortSignal.timeout(remaining));
|
|
1220
|
+
} catch {
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
if (next.id !== initial.id || next.accountId !== accountId || next.threadId !== threadId2 || next.kind !== "send" || next.sourceId !== initial.sourceId || initial.serverMessageId != null && next.serverMessageId !== initial.serverMessageId) break;
|
|
1224
|
+
job = next;
|
|
1225
|
+
delay = Math.min(delay * 2, 1e3);
|
|
1226
|
+
}
|
|
1227
|
+
return job;
|
|
1228
|
+
}
|
|
1229
|
+
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
|
|
1025
1230
|
const deadline = Date.now() + timeoutMs;
|
|
1026
1231
|
let job = initial;
|
|
1027
1232
|
let delay = 250;
|
|
1028
1233
|
while (!terminalJobs.has(job.state) && Date.now() < deadline) {
|
|
1234
|
+
signal?.throwIfAborted();
|
|
1235
|
+
assertCurrent?.();
|
|
1029
1236
|
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
|
|
1030
1237
|
const remaining = deadline - Date.now();
|
|
1031
1238
|
if (remaining <= 0) break;
|
|
1032
|
-
|
|
1239
|
+
signal?.throwIfAborted();
|
|
1240
|
+
assertCurrent?.();
|
|
1241
|
+
job = await client.job(accountId, job.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
|
|
1033
1242
|
delay = Math.min(delay * 2, 1e3);
|
|
1034
1243
|
}
|
|
1035
1244
|
return job;
|
|
1036
1245
|
}
|
|
1037
|
-
function select(assets, type) {
|
|
1246
|
+
function select(assets, type, quality = "best") {
|
|
1038
1247
|
const preferred = type === 34 ? ["normal"] : type === 43 ? ["normal", "original"] : ["original", "standard", "thumbnail", "encoded"];
|
|
1039
1248
|
const rank = (asset) => {
|
|
1040
1249
|
const index = preferred.indexOf(asset.actualVariant ?? asset.variant);
|
|
1041
1250
|
return index < 0 ? preferred.length : index;
|
|
1042
1251
|
};
|
|
1043
|
-
return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (type !== 34 || (asset.actualVariant ?? asset.variant) === "normal"));
|
|
1252
|
+
return [...assets].sort((a, b) => rank(a) - rank(b)).find((asset) => asset.state === "available" && (quality === "best" ? type !== 34 || (asset.actualVariant ?? asset.variant) === "normal" : (asset.actualVariant ?? asset.variant) === quality));
|
|
1044
1253
|
}
|
|
1045
1254
|
async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
1046
1255
|
const configured = cfg?.agents?.defaults?.imageMaxDimensionPx;
|
|
@@ -1054,45 +1263,241 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
|
1054
1263
|
}
|
|
1055
1264
|
throw Error("Native model image representation exceeds its bound");
|
|
1056
1265
|
}
|
|
1266
|
+
async function prepareNativeLink(params) {
|
|
1267
|
+
const { message } = params;
|
|
1268
|
+
const link = message.link;
|
|
1269
|
+
const body = [`[Link]${link.title ? ` ${link.title}` : ""}`, link.description, link.url].filter(Boolean).join("\n").slice(0, 65536);
|
|
1270
|
+
const unavailable = (status, reason) => ({
|
|
1271
|
+
body: `${body}
|
|
1272
|
+
[Link preview ${status}]`,
|
|
1273
|
+
pending: false,
|
|
1274
|
+
retryable: false,
|
|
1275
|
+
reason,
|
|
1276
|
+
attachment: { kind: "image", status, filename: "link-preview" }
|
|
1277
|
+
});
|
|
1278
|
+
if (params.quality && !["best", "thumbnail"].includes(params.quality)) return unavailable("unsupported", "unsupported_media_variant");
|
|
1279
|
+
let prepared;
|
|
1280
|
+
try {
|
|
1281
|
+
prepared = await prepareNativeAttachment({
|
|
1282
|
+
...params,
|
|
1283
|
+
quality: "thumbnail",
|
|
1284
|
+
message: { ...message, type: 3, appMessageType: void 0, quote: void 0, link: void 0 }
|
|
1285
|
+
});
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
params.signal?.throwIfAborted();
|
|
1288
|
+
params.assertCurrent?.();
|
|
1289
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
1290
|
+
if (terminalIdentityErrors.has(error.code) || error.code === "message_recalled") throw error;
|
|
1291
|
+
return { ...unavailable("unavailable", error.code), retryable: error.retryable };
|
|
1292
|
+
}
|
|
1293
|
+
if (prepared.reason === "message_recalled" || terminalIdentityErrors.has(prepared.reason ?? "")) return prepared;
|
|
1294
|
+
return {
|
|
1295
|
+
...prepared,
|
|
1296
|
+
body: `${body}
|
|
1297
|
+
[Link preview]
|
|
1298
|
+
${prepared.body}`,
|
|
1299
|
+
pending: false,
|
|
1300
|
+
...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "link-preview" } } : {}
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
async function prepareNativeQuote(params) {
|
|
1304
|
+
const { client, account, message } = params;
|
|
1305
|
+
const guard = () => {
|
|
1306
|
+
params.signal?.throwIfAborted();
|
|
1307
|
+
params.assertCurrent?.();
|
|
1308
|
+
};
|
|
1309
|
+
guard();
|
|
1310
|
+
const comment = (message.body ?? "").slice(0, 16384);
|
|
1311
|
+
const unavailable = (state) => ({ body: `${comment}${comment ? "\n\n" : ""}[Quoted message ${state}; original lookup is confined to this chat]`, pending: false, retryable: state === "unavailable", quote: { state } });
|
|
1312
|
+
await params.beforeMediaAccess?.();
|
|
1313
|
+
guard();
|
|
1314
|
+
let resolution;
|
|
1315
|
+
try {
|
|
1316
|
+
resolution = await client.quotedMessage(account.native.accountId, message.id);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
guard();
|
|
1319
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
1320
|
+
if (error.code === "message_recalled") return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
|
|
1321
|
+
return terminalIdentityErrors.has(error.code) ? { ...unavailable("unavailable"), retryable: false, reason: error.code } : unavailable("unavailable");
|
|
1322
|
+
}
|
|
1323
|
+
guard();
|
|
1324
|
+
if (resolution.accountId !== message.accountId || resolution.messageId !== message.id || resolution.threadId !== message.threadId) throw Error("Native quote identity does not match its message");
|
|
1325
|
+
if (resolution.state === "recalled" || resolution.message && nativeRecallInfo(resolution.message)) return { ...unavailable("recalled"), retryable: false, reason: "message_recalled" };
|
|
1326
|
+
if (!message.quote) return unavailable("invalid");
|
|
1327
|
+
if (resolution.state === "embedded") {
|
|
1328
|
+
if (resolution.message != null || (message.quote.type === 1 ? typeof resolution.embeddedText !== "string" || resolution.embeddedText.length > 16384 : message.quote.type !== 3 || resolution.embeddedImage !== true)) throw Error("Invalid embedded quote resolution");
|
|
1329
|
+
const author = { senderId: resolution.senderId ?? null, direction: null };
|
|
1330
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) return unavailable("not permitted");
|
|
1331
|
+
guard();
|
|
1332
|
+
const quote2 = { state: "embedded", senderId: resolution.senderId ?? void 0, type: message.quote.type };
|
|
1333
|
+
const prefix2 = `${comment}${comment ? "\n\n" : ""}[Quoted ${message.quote.type === 3 ? "image" : "text"} from ${resolution.senderId ?? "unknown sender"}; embedded in quote, original not stored]
|
|
1334
|
+
`;
|
|
1335
|
+
if (message.quote.type === 1) return { body: `${prefix2}${resolution.embeddedText}
|
|
1336
|
+
[/Quoted message]`, pending: false, quote: quote2 };
|
|
1337
|
+
const beforeMediaAccess2 = async () => {
|
|
1338
|
+
await params.beforeMediaAccess?.();
|
|
1339
|
+
guard();
|
|
1340
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(author)) throw Error("Quoted message is no longer permitted");
|
|
1341
|
+
guard();
|
|
1342
|
+
};
|
|
1343
|
+
const prepared2 = await prepareNativeAttachment({ ...params, message: { ...message, type: 3, appMessageType: void 0, quote: void 0 }, beforeMediaAccess: beforeMediaAccess2 });
|
|
1344
|
+
return {
|
|
1345
|
+
...prepared2,
|
|
1346
|
+
body: `${prefix2}${prepared2.body}
|
|
1347
|
+
[/Quoted message]`,
|
|
1348
|
+
pending: false,
|
|
1349
|
+
quote: quote2,
|
|
1350
|
+
...prepared2.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {}
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
if (resolution.state !== "resolved") {
|
|
1354
|
+
if (!["missing", "invalid", "unsupported", "ambiguous"].includes(resolution.state) || resolution.message != null) throw Error("Invalid native quote resolution");
|
|
1355
|
+
return unavailable(resolution.state);
|
|
1356
|
+
}
|
|
1357
|
+
const original = resolution.message;
|
|
1358
|
+
if (!original || original.accountId !== message.accountId || original.threadId !== message.threadId || original.id === message.id || original.serverId !== message.quote.serverId || original.type !== message.quote.type || ![1, 3].includes(original.type)) throw Error("Native quoted original crosses its message boundary");
|
|
1359
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) return unavailable("not permitted");
|
|
1360
|
+
guard();
|
|
1361
|
+
const quote = { state: "resolved", messageId: original.id, senderId: original.senderId ?? void 0, type: original.type };
|
|
1362
|
+
const prefix = `${comment}${comment ? "\n\n" : ""}[Quoted ${original.type === 3 ? "image" : "text"} from ${original.senderId ?? "unknown sender"}; message ${original.id}]
|
|
1363
|
+
`;
|
|
1364
|
+
if (original.type === 1) return { body: `${prefix}${(original.body ?? "").slice(0, 16384)}
|
|
1365
|
+
[/Quoted message]`, pending: false, quote };
|
|
1366
|
+
const beforeMediaAccess = async () => {
|
|
1367
|
+
await params.beforeMediaAccess?.();
|
|
1368
|
+
guard();
|
|
1369
|
+
if (params.allowQuotedMessage && !await params.allowQuotedMessage(original)) throw Error("Quoted message is no longer permitted");
|
|
1370
|
+
guard();
|
|
1371
|
+
};
|
|
1372
|
+
const prepared = await prepareNativeAttachment({ ...params, message: original, beforeMediaAccess });
|
|
1373
|
+
return {
|
|
1374
|
+
...prepared,
|
|
1375
|
+
body: `${prefix}${prepared.body}
|
|
1376
|
+
[/Quoted message]`,
|
|
1377
|
+
pending: false,
|
|
1378
|
+
quote,
|
|
1379
|
+
...prepared.pending ? { attachment: { kind: "image", status: "pending", filename: "quoted-image" } } : {},
|
|
1380
|
+
suppliedAssets: prepared.suppliedAssets?.map((asset) => ({ ...asset, sourceMessageId: original.id }))
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1057
1383
|
async function prepareNativeAttachment(params) {
|
|
1384
|
+
try {
|
|
1385
|
+
return await prepareNativeAttachmentInput(params);
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
params.signal?.throwIfAborted();
|
|
1388
|
+
params.assertCurrent?.();
|
|
1389
|
+
if (!(error instanceof NativeApiError) || !terminalIdentityErrors.has(error.code)) throw error;
|
|
1390
|
+
if (params.message.type === 49 && params.message.appMessageType === 57) return {
|
|
1391
|
+
body: `${(params.message.body ?? "").slice(0, 16384)}
|
|
1392
|
+
[Quoted message unavailable: stored source identity could not be verified]`,
|
|
1393
|
+
quote: { state: "unavailable" },
|
|
1394
|
+
pending: false,
|
|
1395
|
+
retryable: false,
|
|
1396
|
+
reason: error.code
|
|
1397
|
+
};
|
|
1398
|
+
const attachment = { kind: kind(params.message.type) ?? "file", filename: "attachment", status: "unavailable" };
|
|
1399
|
+
return { body: renderAttachmentBody("", attachment), attachment, pending: false, retryable: false, reason: error.code };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async function prepareNativeAttachmentInput(params) {
|
|
1058
1403
|
const { client, account, message, core } = params;
|
|
1059
1404
|
const attachmentKind = kind(message.type);
|
|
1405
|
+
const guard = () => {
|
|
1406
|
+
params.signal?.throwIfAborted();
|
|
1407
|
+
params.assertCurrent?.();
|
|
1408
|
+
};
|
|
1409
|
+
guard();
|
|
1410
|
+
if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1411
|
+
const authorize = async () => {
|
|
1412
|
+
guard();
|
|
1413
|
+
await params.beforeMediaAccess?.();
|
|
1414
|
+
guard();
|
|
1415
|
+
};
|
|
1416
|
+
if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
|
|
1417
|
+
if (message.type === 49 && message.appMessageType === 19) return { body: message.body?.slice(0, 65536) || "[Forwarded chat history unavailable]", pending: false };
|
|
1418
|
+
if (message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && message.link) return prepareNativeLink(params);
|
|
1419
|
+
if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
|
|
1060
1420
|
if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
|
|
1061
1421
|
const accountId = account.native.accountId;
|
|
1062
1422
|
const maxBytes = Math.min(account.mediaMaxMb * 1024 * 1024, 128 * 1024 * 1024);
|
|
1063
1423
|
const scopedAssets = async () => {
|
|
1424
|
+
await authorize();
|
|
1064
1425
|
const items = (await client.mediaAssets(accountId, message.id)).items;
|
|
1426
|
+
guard();
|
|
1065
1427
|
if (items.some((asset2) => asset2.accountId !== accountId || asset2.messageId !== message.id)) throw Error("Native attachment identity does not match its message");
|
|
1066
1428
|
return items;
|
|
1067
1429
|
};
|
|
1430
|
+
const quality = params.quality ?? "best";
|
|
1431
|
+
const transferQuality = quality === "best" && (message.type === 34 || message.type === 43) ? "normal" : quality;
|
|
1068
1432
|
let assets = await scopedAssets();
|
|
1069
|
-
let asset = select(assets, message.type);
|
|
1070
|
-
|
|
1433
|
+
let asset = select(assets, message.type, quality);
|
|
1434
|
+
const relevant = () => assets.filter((value) => transferQuality === "best" || value.variant === transferQuality);
|
|
1435
|
+
const transferCandidate = () => {
|
|
1436
|
+
const order = ["original", "normal", "standard", "thumbnail", "encoded"];
|
|
1437
|
+
return [...relevant()].sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant))[0];
|
|
1438
|
+
};
|
|
1439
|
+
const initialCandidate = transferCandidate();
|
|
1440
|
+
if (!asset && initialCandidate?.byteLength != null && initialCandidate.byteLength > maxBytes) {
|
|
1441
|
+
const attachment2 = { kind: attachmentKind, filename: basename(initialCandidate.filename ?? "attachment"), status: "too_large" };
|
|
1442
|
+
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false, retryable: false };
|
|
1443
|
+
}
|
|
1444
|
+
let job;
|
|
1445
|
+
let transferError;
|
|
1446
|
+
if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
|
|
1447
|
+
await authorize();
|
|
1448
|
+
job = await client.job(accountId, initialCandidate.jobId, params.signal);
|
|
1449
|
+
guard();
|
|
1450
|
+
if (job.accountId !== accountId || job.kind !== "download" || job.assetId != null && job.assetId !== initialCandidate.id) throw Error("Native download job identity does not match its attachment");
|
|
1451
|
+
assets = await scopedAssets();
|
|
1452
|
+
asset = select(assets, message.type, quality);
|
|
1453
|
+
if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1454
|
+
if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1455
|
+
}
|
|
1456
|
+
if (!asset && !job && params.download !== false && !["expired", "unsupported", "recalled"].includes(transferCandidate()?.state ?? "")) {
|
|
1457
|
+
await authorize();
|
|
1071
1458
|
try {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1459
|
+
job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
|
|
1460
|
+
guard();
|
|
1461
|
+
job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
|
|
1462
|
+
guard();
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
guard();
|
|
1465
|
+
if (!(error instanceof NativeApiError) || terminalIdentityErrors.has(error.code)) throw error;
|
|
1466
|
+
if (error.retryable) return { body: `[${attachmentKind} pending: retrieval is unavailable]`, pending: true, jobId: job?.id, retryable: true };
|
|
1467
|
+
transferError = error;
|
|
1079
1468
|
}
|
|
1469
|
+
assets = await scopedAssets();
|
|
1470
|
+
asset = select(assets, message.type, quality);
|
|
1471
|
+
if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1472
|
+
if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1080
1473
|
}
|
|
1081
|
-
const
|
|
1474
|
+
const candidate = transferCandidate();
|
|
1475
|
+
const filename = basename(asset?.filename ?? candidate?.filename ?? `message-${createHash4("sha256").update(message.id).digest("hex").slice(0, 12)}.bin`).replace(/[\r\n\0]/g, "_");
|
|
1082
1476
|
if (!asset) {
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1477
|
+
if (candidate?.state === "recalled" || transferError?.code === "message_recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1478
|
+
const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
|
|
1479
|
+
const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
|
|
1480
|
+
return {
|
|
1481
|
+
body: renderAttachmentBody("", attachment2),
|
|
1482
|
+
attachment: attachment2,
|
|
1483
|
+
pending: false,
|
|
1484
|
+
jobId: job?.id,
|
|
1485
|
+
retryable: state === "expired" || state === "unsupported" ? false : transferError ? false : job?.retryable ?? params.download === false,
|
|
1486
|
+
reason: transferError?.code ?? (job?.reason && /^[a-z0-9_]{1,80}$/.test(job.reason) ? job.reason : void 0)
|
|
1487
|
+
};
|
|
1086
1488
|
}
|
|
1087
1489
|
if (asset.byteLength != null && asset.byteLength > maxBytes) {
|
|
1088
1490
|
const attachment2 = { kind: attachmentKind, filename, status: "too_large" };
|
|
1089
1491
|
return { body: renderAttachmentBody("", attachment2), attachment: attachment2, pending: false };
|
|
1090
1492
|
}
|
|
1091
|
-
|
|
1493
|
+
await authorize();
|
|
1494
|
+
const content = await client.mediaContent(accountId, asset.id, { maxBytes, signal: params.signal });
|
|
1495
|
+
guard();
|
|
1092
1496
|
const mime = content.contentType.split(";", 1)[0].trim().toLowerCase();
|
|
1093
1497
|
if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime)) throw Error("Native attachment MIME type is unsupported");
|
|
1094
1498
|
const hash = createHash4("sha256").update(content.bytes).digest("hex");
|
|
1095
1499
|
if (!asset.sha256 || hash !== asset.sha256 || asset.byteLength != null && asset.byteLength !== content.bytes.length) throw new Error("Native attachment integrity did not verify");
|
|
1500
|
+
await authorize();
|
|
1096
1501
|
const saved = await saveManagedAttachment({ data: Buffer.from(content.bytes).toString("base64"), mime, filename, maxBytes, saveMediaBuffer: core.channel.media.saveMediaBuffer });
|
|
1097
1502
|
const actual = asset.actualVariant ?? asset.variant;
|
|
1098
1503
|
const attachment = {
|
|
@@ -1105,13 +1510,27 @@ async function prepareNativeAttachment(params) {
|
|
|
1105
1510
|
};
|
|
1106
1511
|
let suppliedPath = saved.path, suppliedMime = mime, suppliedHash = hash, suppliedLength = content.bytes.length;
|
|
1107
1512
|
let body = renderAttachmentBody("", attachment);
|
|
1108
|
-
if (attachmentKind === "image" && params.cfg) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1513
|
+
if (attachmentKind === "image" && (params.cfg || mime === "image/x-wxgf")) {
|
|
1514
|
+
let imageBytes = Buffer.from(content.bytes), imageMime = mime;
|
|
1515
|
+
const wxgf = mime === "image/x-wxgf";
|
|
1516
|
+
if (wxgf) {
|
|
1517
|
+
const preview = asset.preview;
|
|
1518
|
+
if (!preview || preview.sourceSha256 !== hash || !/^[a-f0-9]{64}$/.test(preview.sha256) || !Number.isInteger(preview.byteLength) || preview.byteLength <= 0 || preview.byteLength > 16 * 1024 * 1024 || !["image/jpeg", "image/png"].includes(preview.contentType)) throw Error("Native WXGF preview metadata did not verify");
|
|
1519
|
+
await authorize();
|
|
1520
|
+
const derived = await client.mediaContent(accountId, asset.id, { maxBytes: preview.byteLength, representation: "preview", signal: params.signal });
|
|
1521
|
+
guard();
|
|
1522
|
+
const derivedMime = derived.contentType.split(";", 1)[0].trim().toLowerCase();
|
|
1523
|
+
if (derived.sourceSha256 !== hash || derivedMime !== preview.contentType || derived.bytes.length !== preview.byteLength || createHash4("sha256").update(derived.bytes).digest("hex") !== preview.sha256) throw Error("Native WXGF preview integrity did not verify");
|
|
1524
|
+
imageBytes = Buffer.from(derived.bytes);
|
|
1525
|
+
imageMime = derivedMime;
|
|
1526
|
+
}
|
|
1527
|
+
const representation = await prepareImageRepresentation(imageBytes, imageMime, params.cfg);
|
|
1528
|
+
if (wxgf || representation.preview) {
|
|
1529
|
+
const extension = representation.mime === "image/png" ? "png" : "jpg";
|
|
1111
1530
|
const normalized = await saveManagedAttachment({
|
|
1112
1531
|
data: representation.bytes.toString("base64"),
|
|
1113
1532
|
mime: representation.mime,
|
|
1114
|
-
filename: `${filename}.model
|
|
1533
|
+
filename: `${filename}.model.${extension}`,
|
|
1115
1534
|
maxBytes: 5 * 1024 * 1024,
|
|
1116
1535
|
saveMediaBuffer: core.channel.media.saveMediaBuffer
|
|
1117
1536
|
});
|
|
@@ -1119,13 +1538,18 @@ async function prepareNativeAttachment(params) {
|
|
|
1119
1538
|
suppliedMime = representation.mime;
|
|
1120
1539
|
suppliedHash = normalized.sha256;
|
|
1121
1540
|
suppliedLength = normalized.size;
|
|
1122
|
-
body += `
|
|
1541
|
+
body += wxgf ? `
|
|
1542
|
+
[Model image: WXGF still preview; original retained separately; animation and transparency are not represented]` : `
|
|
1123
1543
|
[Model image: prepared still preview; original retained separately]`;
|
|
1124
1544
|
}
|
|
1125
1545
|
}
|
|
1546
|
+
guard();
|
|
1126
1547
|
return {
|
|
1127
1548
|
body,
|
|
1128
1549
|
attachment,
|
|
1550
|
+
jobId: job?.id,
|
|
1551
|
+
retryable: false,
|
|
1552
|
+
variant: actual,
|
|
1129
1553
|
suppliedAssets: [{
|
|
1130
1554
|
id: asset.id,
|
|
1131
1555
|
sha256: suppliedHash,
|
|
@@ -1146,7 +1570,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1146
1570
|
pending: false
|
|
1147
1571
|
};
|
|
1148
1572
|
}
|
|
1149
|
-
async function sendNativeAttachment(client, account,
|
|
1573
|
+
async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
|
|
1150
1574
|
const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
|
|
1151
1575
|
let bytes;
|
|
1152
1576
|
let filename;
|
|
@@ -1161,7 +1585,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1161
1585
|
}
|
|
1162
1586
|
const reader = response.body?.getReader();
|
|
1163
1587
|
if (!reader) throw new Error("Native outbound source has no bytes");
|
|
1164
|
-
const
|
|
1588
|
+
const chunks2 = [];
|
|
1165
1589
|
let size = 0;
|
|
1166
1590
|
try {
|
|
1167
1591
|
while (true) {
|
|
@@ -1169,7 +1593,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1169
1593
|
if (done) break;
|
|
1170
1594
|
size += value.length;
|
|
1171
1595
|
if (size > max) throw new Error("Native outbound source exceeds configured size");
|
|
1172
|
-
|
|
1596
|
+
chunks2.push(value);
|
|
1173
1597
|
}
|
|
1174
1598
|
} catch (error) {
|
|
1175
1599
|
await reader.cancel();
|
|
@@ -1179,7 +1603,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1179
1603
|
}
|
|
1180
1604
|
bytes = new Uint8Array(size);
|
|
1181
1605
|
let offset = 0;
|
|
1182
|
-
for (const chunk of
|
|
1606
|
+
for (const chunk of chunks2) {
|
|
1183
1607
|
bytes.set(chunk, offset);
|
|
1184
1608
|
offset += chunk.length;
|
|
1185
1609
|
}
|
|
@@ -1191,7 +1615,7 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1191
1615
|
try {
|
|
1192
1616
|
const stat = await file.stat();
|
|
1193
1617
|
if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
|
|
1194
|
-
const
|
|
1618
|
+
const chunks2 = [];
|
|
1195
1619
|
let size = 0;
|
|
1196
1620
|
while (true) {
|
|
1197
1621
|
const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
|
|
@@ -1199,9 +1623,9 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1199
1623
|
if (!bytesRead) break;
|
|
1200
1624
|
size += bytesRead;
|
|
1201
1625
|
if (size > max) throw new Error("Native outbound source exceeds configured size");
|
|
1202
|
-
|
|
1626
|
+
chunks2.push(chunk.subarray(0, bytesRead));
|
|
1203
1627
|
}
|
|
1204
|
-
bytes = Buffer.concat(
|
|
1628
|
+
bytes = Buffer.concat(chunks2, size);
|
|
1205
1629
|
} finally {
|
|
1206
1630
|
await file.close();
|
|
1207
1631
|
}
|
|
@@ -1210,43 +1634,39 @@ async function sendNativeAttachment(client, account, threadId, mediaUrl, audioAs
|
|
|
1210
1634
|
mime = [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extension) ? `image/${extension === ".jpg" ? "jpeg" : extension.slice(1)}` : extension === ".mp4" ? "video/mp4" : [".wav", ".mp3", ".ogg", ".m4a", ".flac", ".aac"].includes(extension) ? `audio/${extension.slice(1)}` : "application/octet-stream";
|
|
1211
1635
|
}
|
|
1212
1636
|
if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
|
|
1637
|
+
await beforeSend?.();
|
|
1213
1638
|
const accountId = account.native.accountId;
|
|
1214
1639
|
const source = await client.upload(accountId, bytes, filename, mime);
|
|
1215
|
-
const input = { threadId, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
|
|
1225
|
-
let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
|
|
1226
|
-
while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
|
|
1227
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
|
|
1228
|
-
const remaining = deadline - Date.now();
|
|
1229
|
-
if (remaining <= 0) break;
|
|
1230
|
-
intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
|
|
1231
|
-
delay = Math.min(delay * 2, 1e3);
|
|
1232
|
-
}
|
|
1233
|
-
if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
|
|
1234
|
-
return intent;
|
|
1640
|
+
const input = { threadId: threadId2, kind: audioAsVoice ? "voice" : mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file", sourceId: source.id, standardSourceId: null, thumbnailSourceId: null, playSeconds: null, durationMs: null };
|
|
1641
|
+
await beforeSend?.();
|
|
1642
|
+
let job = await waitNativeMediaJob(client, accountId, await client.sendMedia(accountId, input, key), audioAsVoice ? 25 * 6e4 : 65e3);
|
|
1643
|
+
if (successfulSendJobs.has(job.state)) job = await waitNativeMediaSendReceipts(client, accountId, threadId2, job, audioAsVoice);
|
|
1644
|
+
if (!successfulSendJobs.has(job.state)) throw new Error(`Native media job ${job.id} requires inspection (${job.state}); retain completed items and do not automatically resend`);
|
|
1645
|
+
const result = mediaSendResult(job);
|
|
1646
|
+
const chunks = Array.isArray(result.chunks) ? result.chunks.filter((chunk) => chunk && typeof chunk === "object") : [];
|
|
1647
|
+
const serverIds = audioAsVoice ? chunks.filter((chunk) => chunk.state === "accepted" && typeof chunk.serverMessageId === "string").map((chunk) => chunk.serverMessageId) : job.serverMessageId ? [job.serverMessageId] : void 0;
|
|
1648
|
+
return nativeOutboundDelivery(client, accountId, threadId2, mediaSendReceiptIds(job, audioAsVoice), { jobId: job.id, state: job.state, serverIds });
|
|
1235
1649
|
}
|
|
1236
1650
|
|
|
1237
1651
|
// src/outbound-media.ts
|
|
1238
|
-
async function sendWeChatMedia(cfg, to,
|
|
1652
|
+
async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
|
|
1239
1653
|
const account = resolveWeChatAccount(cfg);
|
|
1240
1654
|
if (!account?.native) throw new Error("No native daemon configured");
|
|
1241
1655
|
const { client } = await ensureNativeHost(account.native);
|
|
1242
1656
|
const key = idempotencyKey ?? randomUUID2();
|
|
1243
1657
|
if (!mediaUrl) {
|
|
1244
1658
|
if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
|
|
1245
|
-
const send = await submitNativeText(client, account.native.accountId, to,
|
|
1246
|
-
return
|
|
1659
|
+
const send = await submitNativeText(client, account.native.accountId, to, text3, key);
|
|
1660
|
+
return nativeOutboundDelivery(
|
|
1661
|
+
client,
|
|
1662
|
+
account.native.accountId,
|
|
1663
|
+
to,
|
|
1664
|
+
send.localMessageId ? [send.localMessageId] : [],
|
|
1665
|
+
{ sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
|
|
1666
|
+
);
|
|
1247
1667
|
}
|
|
1248
1668
|
const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
|
|
1249
|
-
if (
|
|
1669
|
+
if (text3) await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
|
|
1250
1670
|
return result;
|
|
1251
1671
|
}
|
|
1252
1672
|
|
|
@@ -1273,8 +1693,17 @@ var NativeConsumerState = class {
|
|
|
1273
1693
|
if (parsed.schemaVersion !== 1 || parsed.serverId !== serverId || parsed.accountId !== accountId || !/^\d+$/.test(parsed.cursor) || !parsed.items || typeof parsed.items !== "object") throw new Error("Native consumer identity or schema mismatch; state was preserved");
|
|
1274
1694
|
for (const [id2, item] of Object.entries(parsed.items)) {
|
|
1275
1695
|
if (item.message?.id !== id2 || item.message.accountId !== accountId || typeof item.message.threadId !== "string" || !["received", "dispatching", "handed_off", "completed", "ignored", "needs_review"].includes(item.state)) throw new Error("Invalid native custody record; state was preserved");
|
|
1696
|
+
if (item.cursor != null && !/^\d+$/.test(item.cursor) || item.groupContext != null && typeof item.groupContext !== "boolean") throw new Error("Invalid native group context record; state was preserved");
|
|
1697
|
+
if (item.revision != null && (typeof item.revision !== "string" || !item.revision || item.revision.length > 64)) throw Error("Invalid native custody revision; state was preserved");
|
|
1698
|
+
item.revision ??= randomUUID3();
|
|
1699
|
+
if (item.groupContext && (!item.cursor || item.origin !== "live" || item.state !== "ignored" || item.message.direction !== "incoming" || !item.message.threadId.endsWith("@chatroom"))) throw new Error("Invalid native buffered group context; state was preserved");
|
|
1276
1700
|
if (item.state === "dispatching") item.state = "needs_review";
|
|
1277
1701
|
}
|
|
1702
|
+
if (parsed.recalls != null && (!parsed.recalls || typeof parsed.recalls !== "object" || Array.isArray(parsed.recalls))) throw Error("Invalid native recall state; state was preserved");
|
|
1703
|
+
for (const [key, recall] of Object.entries(parsed.recalls ?? {})) {
|
|
1704
|
+
validateNativeRecall(recall);
|
|
1705
|
+
if (key !== nativeRecallIdentity(recall.threadId, recall.serverId)) throw Error("Invalid native recall identity; state was preserved");
|
|
1706
|
+
}
|
|
1278
1707
|
this.value = parsed;
|
|
1279
1708
|
this.persist();
|
|
1280
1709
|
}
|
|
@@ -1282,12 +1711,22 @@ var NativeConsumerState = class {
|
|
|
1282
1711
|
get cursor() {
|
|
1283
1712
|
return this.value.cursor;
|
|
1284
1713
|
}
|
|
1714
|
+
get accountId() {
|
|
1715
|
+
return this.value.accountId;
|
|
1716
|
+
}
|
|
1285
1717
|
items() {
|
|
1286
1718
|
return Object.values(this.value.items);
|
|
1287
1719
|
}
|
|
1288
1720
|
get(messageId) {
|
|
1289
1721
|
return this.value.items[messageId];
|
|
1290
1722
|
}
|
|
1723
|
+
isRecalled(message) {
|
|
1724
|
+
return !!nativeRecallInfo(message) || !!(message.serverId && this.value.recalls?.[nativeRecallIdentity(message.threadId, message.serverId)]);
|
|
1725
|
+
}
|
|
1726
|
+
isCurrent(message, revision) {
|
|
1727
|
+
const current = this.get(message.id);
|
|
1728
|
+
return !!current && (revision === void 0 || current.revision === revision) && !this.isRecalled(message) && JSON.stringify(current.message) === JSON.stringify(message);
|
|
1729
|
+
}
|
|
1291
1730
|
persist() {
|
|
1292
1731
|
const directory = dirname2(this.file);
|
|
1293
1732
|
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
@@ -1331,20 +1770,131 @@ var NativeConsumerState = class {
|
|
|
1331
1770
|
accept(cursor, message, origin = "unknown") {
|
|
1332
1771
|
if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw new Error("Native journal cursor cannot move backward");
|
|
1333
1772
|
if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
|
|
1773
|
+
let replaced = false;
|
|
1334
1774
|
this.mutate(() => {
|
|
1335
|
-
if (message &&
|
|
1336
|
-
|
|
1775
|
+
if (message?.serverId && nativeRecallInfo(message)) this.rememberRecall({ messageId: message.id, threadId: message.threadId, serverId: message.serverId, ...nativeRecallInfo(message) });
|
|
1776
|
+
if (message?.serverId) {
|
|
1777
|
+
const recall = this.value.recalls?.[nativeRecallIdentity(message.threadId, message.serverId)];
|
|
1778
|
+
if (recall) message = recalledNativeMessage(message, recall);
|
|
1779
|
+
}
|
|
1780
|
+
const existing = message ? this.value.items[message.id] : void 0;
|
|
1781
|
+
replaced = !!message && !nativeRecallInfo(message) && !!existing && (existing.message.threadId !== message.threadId || existing.message.serverId !== message.serverId);
|
|
1782
|
+
if (message && (!existing || replaced)) {
|
|
1783
|
+
const completed = Object.entries(this.value.items).filter(([, item]) => !item.readPending && !item.groupContext && item.phoneReadState !== "needs_review" && item.phoneReadState !== "rejected" && ["completed", "ignored"].includes(item.state));
|
|
1337
1784
|
for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
|
|
1338
|
-
if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
|
|
1339
|
-
|
|
1785
|
+
if (!existing && Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
|
|
1786
|
+
if (replaced) {
|
|
1787
|
+
for (const item of this.items()) {
|
|
1788
|
+
const manifest = item.dispatchManifest;
|
|
1789
|
+
if (!manifest || ![
|
|
1790
|
+
...manifest.messageIds,
|
|
1791
|
+
...(manifest.historyInputs ?? []).map((input) => input.id),
|
|
1792
|
+
...manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
|
|
1793
|
+
].includes(message.id)) continue;
|
|
1794
|
+
if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
|
|
1795
|
+
item.dispatchManifest = void 0;
|
|
1796
|
+
item.manifestHash = void 0;
|
|
1797
|
+
item.readPending = false;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
this.value.items[message.id] = { message, revision: randomUUID3(), origin, state: nativeRecallInfo(message) ? "ignored" : "received", cursor };
|
|
1340
1801
|
}
|
|
1341
1802
|
this.value.cursor = cursor;
|
|
1342
1803
|
});
|
|
1804
|
+
return replaced;
|
|
1805
|
+
}
|
|
1806
|
+
/** Persist invalidation and the event cursor together, before consumer ACK. */
|
|
1807
|
+
acceptRecall(cursor, recall) {
|
|
1808
|
+
validateNativeRecall(recall);
|
|
1809
|
+
if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw Error("Native journal cursor cannot move backward");
|
|
1810
|
+
let affected = [];
|
|
1811
|
+
this.mutate(() => {
|
|
1812
|
+
affected = this.rememberRecall(recall);
|
|
1813
|
+
this.value.cursor = cursor;
|
|
1814
|
+
});
|
|
1815
|
+
return affected;
|
|
1343
1816
|
}
|
|
1344
|
-
|
|
1817
|
+
rememberRecall(recall) {
|
|
1818
|
+
const hinted = recall.messageId ? this.get(recall.messageId)?.message : void 0;
|
|
1819
|
+
if (hinted && (hinted.threadId !== recall.threadId || hinted.serverId !== recall.serverId)) {
|
|
1820
|
+
const { messageId: _stale, ...exact } = recall;
|
|
1821
|
+
recall = exact;
|
|
1822
|
+
}
|
|
1823
|
+
const key = nativeRecallIdentity(recall.threadId, recall.serverId);
|
|
1824
|
+
this.value.recalls ??= {};
|
|
1825
|
+
this.value.recalls[key] ??= {
|
|
1826
|
+
threadId: recall.threadId,
|
|
1827
|
+
serverId: recall.serverId,
|
|
1828
|
+
observedAt: recall.observedAt,
|
|
1829
|
+
...recall.messageId ? { messageId: recall.messageId } : {},
|
|
1830
|
+
...recall.recalledAt ? { recalledAt: recall.recalledAt } : {}
|
|
1831
|
+
};
|
|
1832
|
+
const affected = /* @__PURE__ */ new Set();
|
|
1833
|
+
for (const item of this.items()) {
|
|
1834
|
+
if (item.message.threadId !== recall.threadId) continue;
|
|
1835
|
+
const target = item.message.serverId === recall.serverId;
|
|
1836
|
+
const quote = item.message.quote?.serverId === recall.serverId;
|
|
1837
|
+
if (!target && !quote) continue;
|
|
1838
|
+
affected.add(item.message.id);
|
|
1839
|
+
item.message = target ? recalledNativeMessage(item.message, recall) : { ...item.message, quote: void 0 };
|
|
1840
|
+
if (target) item.groupContext = false;
|
|
1841
|
+
item.readPending = false;
|
|
1842
|
+
if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
|
|
1843
|
+
else if (target && item.state === "received") item.state = "ignored";
|
|
1844
|
+
item.invalidatedByRecall = true;
|
|
1845
|
+
item.dispatchManifest = void 0;
|
|
1846
|
+
item.manifestHash = void 0;
|
|
1847
|
+
}
|
|
1848
|
+
for (const item of this.items()) {
|
|
1849
|
+
const manifest = item.dispatchManifest;
|
|
1850
|
+
if (item.message.threadId !== recall.threadId || !manifest) continue;
|
|
1851
|
+
const references = [
|
|
1852
|
+
...manifest.messageIds,
|
|
1853
|
+
...(manifest.historyInputs ?? []).map((input) => input.id),
|
|
1854
|
+
...manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
|
|
1855
|
+
];
|
|
1856
|
+
if (!references.some((id2) => affected.has(id2) || id2 === recall.messageId)) continue;
|
|
1857
|
+
affected.add(item.message.id);
|
|
1858
|
+
item.invalidatedByRecall = true;
|
|
1859
|
+
item.readPending = false;
|
|
1860
|
+
if (["dispatching", "handed_off"].includes(item.state)) item.state = "needs_review";
|
|
1861
|
+
item.dispatchManifest = void 0;
|
|
1862
|
+
item.manifestHash = void 0;
|
|
1863
|
+
}
|
|
1864
|
+
return [...affected];
|
|
1865
|
+
}
|
|
1866
|
+
update(messageId, update, revision) {
|
|
1345
1867
|
const item = this.value.items[messageId];
|
|
1868
|
+
if (revision !== void 0 && item?.revision !== revision) return false;
|
|
1346
1869
|
if (!item) throw new Error("Unknown native custody message");
|
|
1347
1870
|
this.mutate(() => Object.assign(this.value.items[messageId], update));
|
|
1871
|
+
return true;
|
|
1872
|
+
}
|
|
1873
|
+
groupHistory(threadId2, beforeId, limit) {
|
|
1874
|
+
const before = this.value.items[beforeId]?.cursor;
|
|
1875
|
+
if (!before || limit <= 0) return [];
|
|
1876
|
+
return this.items().filter((item) => item.groupContext && item.state === "ignored" && item.origin === "live" && item.message.threadId === threadId2 && item.cursor && BigInt(item.cursor) < BigInt(before)).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : BigInt(a.cursor) > BigInt(b.cursor) ? 1 : 0).slice(-limit);
|
|
1877
|
+
}
|
|
1878
|
+
bufferGroupMessage(messageId, limit, revision) {
|
|
1879
|
+
const item = this.value.items[messageId];
|
|
1880
|
+
if (revision !== void 0 && item?.revision !== revision) return false;
|
|
1881
|
+
if (!item || item.state !== "received" || item.origin !== "live" || item.message.direction !== "incoming" || !item.cursor || !item.message.threadId.endsWith("@chatroom") || !Number.isInteger(limit) || limit < 0 || limit > 200) throw new Error("Invalid native group history admission");
|
|
1882
|
+
this.mutate(() => {
|
|
1883
|
+
Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
|
|
1884
|
+
const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
|
|
1885
|
+
const thread3 = history.filter((value) => value.message.threadId === item.message.threadId);
|
|
1886
|
+
for (const value of thread3.slice(0, Math.max(0, thread3.length - limit))) value.groupContext = false;
|
|
1887
|
+
const retained = history.filter((value) => value.groupContext);
|
|
1888
|
+
for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
|
|
1889
|
+
});
|
|
1890
|
+
return true;
|
|
1891
|
+
}
|
|
1892
|
+
beginDispatch(messageId, dispatchManifest, historyIds = []) {
|
|
1893
|
+
if (this.value.items[messageId]?.state !== "received" || this.isRecalled(this.value.items[messageId].message) || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
|
|
1894
|
+
this.mutate(() => {
|
|
1895
|
+
Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest, invalidatedByRecall: false });
|
|
1896
|
+
for (const id2 of historyIds) this.value.items[id2].groupContext = false;
|
|
1897
|
+
});
|
|
1348
1898
|
}
|
|
1349
1899
|
/** Synchronous bounded receipt at the SDK's prepared-run callback boundary. */
|
|
1350
1900
|
preparedCallback(messageIds, readPolicy, previous) {
|
|
@@ -1355,6 +1905,7 @@ var NativeConsumerState = class {
|
|
|
1355
1905
|
if (!item || item.state !== "dispatching") throw new Error("Prepared input manifest is not in dispatch custody");
|
|
1356
1906
|
return item;
|
|
1357
1907
|
});
|
|
1908
|
+
const revisions = items.map((item) => item.revision);
|
|
1358
1909
|
if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
|
|
1359
1910
|
const manifestHash = createHash6("sha256").update(JSON.stringify({
|
|
1360
1911
|
accountId: this.value.accountId,
|
|
@@ -1365,8 +1916,15 @@ var NativeConsumerState = class {
|
|
|
1365
1916
|
const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
|
|
1366
1917
|
if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
|
|
1367
1918
|
this.mutate(() => {
|
|
1368
|
-
|
|
1919
|
+
const currentHash = createHash6("sha256").update(JSON.stringify({
|
|
1920
|
+
accountId: this.value.accountId,
|
|
1921
|
+
messageIds: exact,
|
|
1922
|
+
dispatchManifests: exact.map((id2) => this.value.items[id2]?.dispatchManifest ?? null)
|
|
1923
|
+
})).digest("hex");
|
|
1924
|
+
for (const [index, id2] of exact.entries()) {
|
|
1369
1925
|
const item = this.value.items[id2];
|
|
1926
|
+
if (!item || item.invalidatedByRecall || this.isRecalled(item.message)) throw Error("Native prepared input was invalidated by a recall");
|
|
1927
|
+
if (item.revision !== revisions[index] || currentHash !== manifestHash) throw Error("Native prepared input source identity changed");
|
|
1370
1928
|
if (item.runId && item.runId !== runId) throw new Error("Native prepared manifest was assigned to another run; operator review required");
|
|
1371
1929
|
Object.assign(item, { state: "handed_off", runId, manifestHash, readPending: readPolicy === "on_model_dispatch", handoffUnconfirmed: false });
|
|
1372
1930
|
}
|
|
@@ -1385,12 +1943,17 @@ var digest = (value) => createHash7("sha256").update(value).digest("hex");
|
|
|
1385
1943
|
function prune() {
|
|
1386
1944
|
for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
|
|
1387
1945
|
}
|
|
1946
|
+
function nativeSourceMessageSid(message) {
|
|
1947
|
+
return `wechat-native:${message.accountId}:${message.id}:${digest(JSON.stringify([message.threadId, message.serverId ?? null]))}`;
|
|
1948
|
+
}
|
|
1388
1949
|
function nativeDispatchManifest(params) {
|
|
1950
|
+
const history = params.history ?? [];
|
|
1951
|
+
if (history.length > 200 || history.some((message) => message.accountId !== params.message.accountId || message.threadId !== params.message.threadId || message.id === params.message.id)) throw Error("Invalid native history manifest");
|
|
1389
1952
|
const assets = Object.freeze((params.assets ?? []).map((asset) => {
|
|
1390
|
-
if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || asset.contextRole !== "
|
|
1953
|
+
if (typeof asset.id !== "string" || !asset.id || asset.id.length > 2048 || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > 128 * 1024 * 1024 || typeof asset.path !== "string" || !isAbsolute3(asset.path) || asset.path.length > 4096 || typeof asset.mime !== "string" || asset.mime.length > 128 || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(asset.mime) || !["image", "audio", "video", "file"].includes(asset.kind) || !["model_image", "file_reference"].includes(asset.role) || asset.role === "model_image" && (asset.kind !== "image" || !asset.mime.startsWith("image/") || !asset.runtimeMedia) || !["trigger", "history"].includes(asset.contextRole) || asset.sourceMessageId != null && (typeof asset.sourceMessageId !== "string" || !asset.sourceMessageId || asset.sourceMessageId.length > 8192 || ![params.message, ...history].some((message) => message.id === asset.messageId && message.type === 49 && message.appMessageType === 57 && message.quote?.type === 3)) || asset.contextRole === "history" && !history.some((message) => message.id === asset.messageId)) throw Error("Invalid native supplied asset manifest");
|
|
1391
1954
|
return Object.freeze({ ...asset });
|
|
1392
1955
|
}));
|
|
1393
|
-
const messageSid =
|
|
1956
|
+
const messageSid = nativeSourceMessageSid(params.message);
|
|
1394
1957
|
const manifest = {
|
|
1395
1958
|
schemaVersion: 2,
|
|
1396
1959
|
runId: randomUUID4(),
|
|
@@ -1413,10 +1976,19 @@ function nativeDispatchManifest(params) {
|
|
|
1413
1976
|
params.message.type,
|
|
1414
1977
|
params.message.body,
|
|
1415
1978
|
params.message.mentions,
|
|
1416
|
-
params.message.serverId
|
|
1979
|
+
params.message.serverId,
|
|
1980
|
+
params.message.appMessageType,
|
|
1981
|
+
params.message.quote,
|
|
1982
|
+
params.message.link
|
|
1417
1983
|
])),
|
|
1418
1984
|
role: "trigger"
|
|
1419
1985
|
})]),
|
|
1986
|
+
...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
|
|
1987
|
+
id: message.id,
|
|
1988
|
+
version: message.version ?? "",
|
|
1989
|
+
contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId, message.appMessageType, message.quote, message.link])),
|
|
1990
|
+
role: "history"
|
|
1991
|
+
}))) } : {},
|
|
1420
1992
|
assets,
|
|
1421
1993
|
policySha256: digest(JSON.stringify(params.policy ?? null)),
|
|
1422
1994
|
// Exact released 2026.10.2 source-turn identity. Requiring it in the later
|
|
@@ -1430,30 +2002,40 @@ function trackNativePreparedInput(manifest, record) {
|
|
|
1430
2002
|
if (!enabled || pending.size >= 1024) return;
|
|
1431
2003
|
pending.set(manifest.runId, { manifest, record, expires: Date.now() + 15 * 60 * 1e3 });
|
|
1432
2004
|
}
|
|
2005
|
+
function invalidateNativePreparedInputs(accountId, messageIds) {
|
|
2006
|
+
const ids = new Set(messageIds);
|
|
2007
|
+
for (const [runId, item] of pending) {
|
|
2008
|
+
if (item.manifest.accountId === accountId && [
|
|
2009
|
+
...item.manifest.messageIds,
|
|
2010
|
+
...(item.manifest.historyInputs ?? []).map((input) => input.id),
|
|
2011
|
+
...item.manifest.assets.flatMap((asset) => asset.sourceMessageId ? [asset.sourceMessageId] : [])
|
|
2012
|
+
].some((id2) => ids.has(id2))) pending.delete(runId);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1433
2015
|
function promptMatches(prompt, manifest) {
|
|
1434
2016
|
if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 1024 * 1024) return false;
|
|
1435
|
-
const
|
|
1436
|
-
const candidates = [
|
|
2017
|
+
const text3 = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
|
|
2018
|
+
const candidates = [text3];
|
|
1437
2019
|
const sender = `Conversation info: \u27E6openclaw:ctx\u27E7
|
|
1438
2020
|
\`\`\`json
|
|
1439
2021
|
${JSON.stringify({ sender: { id: manifest.senderId } })}
|
|
1440
2022
|
\`\`\`
|
|
1441
2023
|
|
|
1442
2024
|
`;
|
|
1443
|
-
if (manifest.threadId.endsWith("@chatroom") &&
|
|
1444
|
-
return candidates.some((
|
|
2025
|
+
if (manifest.threadId.endsWith("@chatroom") && text3.startsWith(sender)) candidates.push(text3.slice(sender.length));
|
|
2026
|
+
return candidates.some((text4) => normalizedPromptMatches(text4, manifest));
|
|
1445
2027
|
}
|
|
1446
|
-
function normalizedPromptMatches(
|
|
2028
|
+
function normalizedPromptMatches(text3, manifest) {
|
|
1447
2029
|
const runtimeAssets = manifest.assets.filter((asset) => asset.runtimeMedia);
|
|
1448
2030
|
if (runtimeAssets.length) {
|
|
1449
2031
|
if (runtimeAssets.length !== 1) return false;
|
|
1450
2032
|
const asset = runtimeAssets[0];
|
|
1451
|
-
const header = /^\[media attached: ([^\r\n]+) \(([^)\r\n]+)\) \| (media:\/\/inbound\/[^\]\r\n]+)\]\n/.exec(
|
|
2033
|
+
const header = /^\[media attached: ([^\r\n]+) \(([^)\r\n]+)\) \| (media:\/\/inbound\/[^\]\r\n]+)\]\n/.exec(text3);
|
|
1452
2034
|
if (!header || !isAbsolute3(header[1]) || basename2(header[1]) !== `input-${basename2(asset.path)}` || header[2] !== asset.mime || header[3] !== `media://inbound/${basename2(asset.path)}`) return false;
|
|
1453
|
-
|
|
1454
|
-
if (asset.kind === "video")
|
|
2035
|
+
text3 = text3.slice(header[0].length);
|
|
2036
|
+
if (asset.kind === "video") text3 = text3.replace(/\n\n\[Video attachment not analyzed: video understanding is disabled\]$/, "");
|
|
1455
2037
|
}
|
|
1456
|
-
return digest(
|
|
2038
|
+
return digest(text3) === manifest.inputBodySha256;
|
|
1457
2039
|
}
|
|
1458
2040
|
function currentMessageFacts(messages) {
|
|
1459
2041
|
if (!Array.isArray(messages) || messages.length > 1e3) return [];
|
|
@@ -1514,8 +2096,8 @@ function observeNativeAgentEnd(event, ctx) {
|
|
|
1514
2096
|
const blocks = typeof user.content === "string" ? [{ type: "text", text: user.content }] : user.content;
|
|
1515
2097
|
if (!Array.isArray(blocks) || blocks.length > 32) return;
|
|
1516
2098
|
if (blocks.some((block) => !["text", "image"].includes(block?.type) || block.type === "text" && typeof block.text !== "string")) return;
|
|
1517
|
-
const
|
|
1518
|
-
if (!promptMatches(
|
|
2099
|
+
const text3 = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
|
|
2100
|
+
if (!promptMatches(text3, m)) return;
|
|
1519
2101
|
const images = blocks.filter((block) => block?.type === "image");
|
|
1520
2102
|
const expectedImages = m.assets.filter((asset) => asset.role === "model_image");
|
|
1521
2103
|
if (images.length !== expectedImages.length) return;
|
|
@@ -1525,7 +2107,7 @@ function observeNativeAgentEnd(event, ctx) {
|
|
|
1525
2107
|
const bytes = Buffer.from(image.data, "base64");
|
|
1526
2108
|
if (bytes.length !== asset.byteLength || digest(bytes) !== asset.sha256) return;
|
|
1527
2109
|
}
|
|
1528
|
-
if (m.assets.some((asset) => asset.role === "file_reference" && !
|
|
2110
|
+
if (m.assets.some((asset) => asset.role === "file_reference" && !text3.includes(asset.path))) return;
|
|
1529
2111
|
item.record(m.runId);
|
|
1530
2112
|
pending.delete(m.runId);
|
|
1531
2113
|
}
|
|
@@ -1543,28 +2125,119 @@ function registerNativePreparedBoundary(api) {
|
|
|
1543
2125
|
};
|
|
1544
2126
|
}
|
|
1545
2127
|
|
|
2128
|
+
// src/native-history.ts
|
|
2129
|
+
import { buildHistoryContextFromEntries, buildInboundHistoryFromEntries, DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
|
|
2130
|
+
function nativeGroupHistoryLimit(cfg) {
|
|
2131
|
+
const limit = cfg?.messages?.groupChat?.historyLimit;
|
|
2132
|
+
return Number.isInteger(limit) && limit >= 0 ? Math.min(limit, 200) : DEFAULT_GROUP_HISTORY_LIMIT;
|
|
2133
|
+
}
|
|
2134
|
+
async function transcribeNativeAttachment(prepared, core, cfg) {
|
|
2135
|
+
const attachment = prepared.attachment;
|
|
2136
|
+
if (attachment?.kind !== "audio" || attachment.status !== "ready" || !attachment.path) return prepared;
|
|
2137
|
+
let transcript = "";
|
|
2138
|
+
try {
|
|
2139
|
+
transcript = (await core.mediaUnderstanding.transcribeAudioFile({ filePath: attachment.path, cfg, mime: attachment.mime })).text?.trim() ?? "";
|
|
2140
|
+
} catch {
|
|
2141
|
+
}
|
|
2142
|
+
return { ...prepared, body: `${prepared.body}
|
|
2143
|
+
${transcript ? `[Voice transcript: ${transcript.slice(0, 1e4)}]` : "[Voice transcription unavailable]"}` };
|
|
2144
|
+
}
|
|
2145
|
+
async function prepareNativeHistory(messages, prepare) {
|
|
2146
|
+
const result = new Array(messages.length);
|
|
2147
|
+
let next = 0;
|
|
2148
|
+
await Promise.all(Array.from({ length: Math.min(3, messages.length) }, async () => {
|
|
2149
|
+
for (let index = next++; index < messages.length; index = next++) {
|
|
2150
|
+
const message = messages[index];
|
|
2151
|
+
let prepared;
|
|
2152
|
+
try {
|
|
2153
|
+
prepared = await prepare(message);
|
|
2154
|
+
} catch {
|
|
2155
|
+
prepared = { body: `[Attachment unavailable for message ${message.id}: retrieval or integrity verification failed]`, pending: false };
|
|
2156
|
+
}
|
|
2157
|
+
if (message.type === 1 && prepared.body.length > 4096) prepared = { ...prepared, body: `${prepared.body.slice(0, 4096)}
|
|
2158
|
+
[Message truncated; use wechat_history or wechat_search for the stored message]` };
|
|
2159
|
+
result[index] = { message, prepared };
|
|
2160
|
+
}
|
|
2161
|
+
}));
|
|
2162
|
+
return result;
|
|
2163
|
+
}
|
|
2164
|
+
function buildNativeContext(history, message, prepared) {
|
|
2165
|
+
const all = [...history, { message, prepared }];
|
|
2166
|
+
const image = history.length ? [...all].reverse().find((item) => item.prepared.attachment?.kind === "image" && item.prepared.attachment.status === "ready" && item.prepared.mediaPath) : void 0;
|
|
2167
|
+
const selected = history.length ? image : all.at(-1);
|
|
2168
|
+
const assets = all.flatMap((item) => (item.prepared.suppliedAssets ?? []).map((asset) => {
|
|
2169
|
+
const contextRole = item.message.id === message.id ? "trigger" : "history";
|
|
2170
|
+
if (item === selected) return { ...asset, contextRole, messageId: item.message.id };
|
|
2171
|
+
return {
|
|
2172
|
+
...asset,
|
|
2173
|
+
contextRole,
|
|
2174
|
+
messageId: item.message.id,
|
|
2175
|
+
runtimeMedia: false,
|
|
2176
|
+
role: "file_reference",
|
|
2177
|
+
path: asset.sourcePath ?? asset.path,
|
|
2178
|
+
sha256: asset.sourceSha256 ?? asset.sha256,
|
|
2179
|
+
byteLength: asset.sourceByteLength ?? asset.byteLength,
|
|
2180
|
+
mime: asset.sourceMime ?? asset.mime
|
|
2181
|
+
};
|
|
2182
|
+
}));
|
|
2183
|
+
const entries = history.map((item) => ({
|
|
2184
|
+
sender: item.message.senderId,
|
|
2185
|
+
body: item.prepared.body,
|
|
2186
|
+
timestamp: item.message.createdAt ? Date.parse(item.message.createdAt) : void 0,
|
|
2187
|
+
messageId: item.message.id
|
|
2188
|
+
}));
|
|
2189
|
+
const inputBody = entries.length ? buildHistoryContextFromEntries({
|
|
2190
|
+
entries,
|
|
2191
|
+
currentMessage: prepared.body,
|
|
2192
|
+
excludeLast: false,
|
|
2193
|
+
formatEntry: (entry) => `${entry.sender}: ${entry.body}`
|
|
2194
|
+
}) : prepared.body;
|
|
2195
|
+
const attachments = all.flatMap((item) => {
|
|
2196
|
+
const attachment = item.prepared.attachment;
|
|
2197
|
+
return attachment?.status === "ready" && attachment.path ? [{
|
|
2198
|
+
path: attachment.path,
|
|
2199
|
+
filename: attachment.filename,
|
|
2200
|
+
kind: attachment.kind,
|
|
2201
|
+
messageId: item.message.id,
|
|
2202
|
+
sender: item.message.senderId
|
|
2203
|
+
}] : [];
|
|
2204
|
+
});
|
|
2205
|
+
return {
|
|
2206
|
+
inputBody,
|
|
2207
|
+
attachments,
|
|
2208
|
+
assets,
|
|
2209
|
+
history: buildInboundHistoryFromEntries({ entries, limit: entries.length }),
|
|
2210
|
+
mediaPath: selected?.prepared.mediaPath,
|
|
2211
|
+
mediaMime: selected?.prepared.mediaMime
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
|
|
1546
2215
|
// src/native-monitor.ts
|
|
1547
2216
|
function nativeWasMentioned(message) {
|
|
1548
2217
|
return message.mentions?.state === "known" && message.mentions.self === true;
|
|
1549
2218
|
}
|
|
1550
2219
|
async function projectNativeRead(client, accountId, state) {
|
|
1551
|
-
|
|
2220
|
+
const pending2 = state.items().filter((value) => value.readPending);
|
|
2221
|
+
const threads = [...new Set(pending2.map((item) => item.message.threadId))];
|
|
2222
|
+
for (const threadId2 of threads) {
|
|
1552
2223
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1553
2224
|
let cursor;
|
|
1554
|
-
let
|
|
2225
|
+
let thread3;
|
|
1555
2226
|
const seen = /* @__PURE__ */ new Set();
|
|
1556
2227
|
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
1557
2228
|
const page = await client.threads(accountId, cursor);
|
|
1558
|
-
|
|
1559
|
-
if (
|
|
2229
|
+
thread3 = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
|
|
2230
|
+
if (thread3 || !page.nextCursor) break;
|
|
1560
2231
|
if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
|
|
1561
2232
|
seen.add(page.nextCursor);
|
|
1562
2233
|
cursor = page.nextCursor;
|
|
1563
2234
|
}
|
|
1564
|
-
if (!
|
|
2235
|
+
if (!thread3) break;
|
|
1565
2236
|
try {
|
|
1566
|
-
await client.markRead(accountId,
|
|
1567
|
-
|
|
2237
|
+
const receipt = await client.markRead(accountId, thread3.id, thread3.readStateVersion);
|
|
2238
|
+
const notification = receipt?.phoneNotification?.state;
|
|
2239
|
+
const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
|
|
2240
|
+
for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState }, item.revision);
|
|
1568
2241
|
break;
|
|
1569
2242
|
} catch (error) {
|
|
1570
2243
|
if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
|
|
@@ -1573,12 +2246,72 @@ async function projectNativeRead(client, accountId, state) {
|
|
|
1573
2246
|
}
|
|
1574
2247
|
}
|
|
1575
2248
|
}
|
|
2249
|
+
var NativeInputInvalidated = class extends Error {
|
|
2250
|
+
};
|
|
2251
|
+
async function acceptNativeEvent(client, accountId, state, event) {
|
|
2252
|
+
if (state.accountId !== accountId) throw Error("Native event crosses its custody account boundary");
|
|
2253
|
+
if (event.kind === "message.recalled") {
|
|
2254
|
+
const affected = state.acceptRecall(event.cursor, event.data);
|
|
2255
|
+
invalidateNativePreparedInputs(accountId, affected);
|
|
2256
|
+
return void 0;
|
|
2257
|
+
}
|
|
2258
|
+
let message;
|
|
2259
|
+
if (event.kind === "message.committed") {
|
|
2260
|
+
if (typeof event.data.messageId !== "string") throw Error("Native committed message identity is missing; cursor retained");
|
|
2261
|
+
message = await client.message(accountId, event.data.messageId);
|
|
2262
|
+
if (message.id !== event.data.messageId || message.accountId !== accountId || message.threadId !== event.data.threadId) throw Error("Native committed message identity mismatch");
|
|
2263
|
+
}
|
|
2264
|
+
const replaced = state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
|
|
2265
|
+
if (replaced && message) invalidateNativePreparedInputs(accountId, [message.id]);
|
|
2266
|
+
return message;
|
|
2267
|
+
}
|
|
2268
|
+
async function recheckNativeInputs(client, state, messages) {
|
|
2269
|
+
for (const message of messages) {
|
|
2270
|
+
if (!state.isCurrent(message)) throw new NativeInputInvalidated();
|
|
2271
|
+
const current = await client.message(message.accountId, message.id);
|
|
2272
|
+
if (current.accountId !== message.accountId || current.threadId !== message.threadId || current.id !== message.id || current.serverId !== message.serverId) throw Error("Native input identity changed; delivery held for review");
|
|
2273
|
+
const recall = nativeRecallInfo(current);
|
|
2274
|
+
if (recall && current.serverId) {
|
|
2275
|
+
const affected = state.acceptRecall(state.cursor, { ...recall, messageId: current.id, threadId: current.threadId, serverId: current.serverId });
|
|
2276
|
+
invalidateNativePreparedInputs(message.accountId, [current.id, ...affected]);
|
|
2277
|
+
throw new NativeInputInvalidated();
|
|
2278
|
+
}
|
|
2279
|
+
if (message.quote) {
|
|
2280
|
+
const quote = await client.quotedMessage(message.accountId, message.id);
|
|
2281
|
+
if (quote.accountId !== message.accountId || quote.threadId !== message.threadId || quote.messageId !== message.id) throw Error("Native quote identity changed");
|
|
2282
|
+
if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) {
|
|
2283
|
+
const affected = state.acceptRecall(state.cursor, {
|
|
2284
|
+
threadId: message.threadId,
|
|
2285
|
+
serverId: message.quote.serverId,
|
|
2286
|
+
observedAt: quote.message && nativeRecallInfo(quote.message)?.observedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
2287
|
+
});
|
|
2288
|
+
invalidateNativePreparedInputs(message.accountId, affected);
|
|
2289
|
+
throw new NativeInputInvalidated();
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
if (!state.isCurrent(message)) throw new NativeInputInvalidated();
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
1576
2295
|
async function dispatchNativeMessage(params) {
|
|
2296
|
+
const revision = params.state.get(params.message.id)?.revision;
|
|
2297
|
+
try {
|
|
2298
|
+
return await dispatchNativeMessageInput(params, revision);
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
const item = params.state.get(params.message.id);
|
|
2301
|
+
if (params.state.isRecalled(params.message)) return "ignored";
|
|
2302
|
+
if (error instanceof NativeInputInvalidated && item?.state === "received") return "pending";
|
|
2303
|
+
if (item) params.state.update(params.message.id, { state: "needs_review" }, revision);
|
|
2304
|
+
return "needs_review";
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
async function dispatchNativeMessageInput(params, revision) {
|
|
1577
2308
|
const { client, state, account, message, cfg, core } = params;
|
|
2309
|
+
const readPolicy = account.native?.readPolicy ?? "on_inbound";
|
|
1578
2310
|
const custody = state.get(message.id);
|
|
1579
|
-
if (
|
|
2311
|
+
if (state.isRecalled(message)) return "ignored";
|
|
2312
|
+
if (!custody || custody.state !== "received" || !state.isCurrent(message, revision)) return "needs_review";
|
|
1580
2313
|
if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
|
|
1581
|
-
state.update(message.id, { state: "ignored" });
|
|
2314
|
+
state.update(message.id, { state: "ignored" }, revision);
|
|
1582
2315
|
return "ignored";
|
|
1583
2316
|
}
|
|
1584
2317
|
const isGroup = message.threadId.endsWith("@chatroom");
|
|
@@ -1586,8 +2319,20 @@ async function dispatchNativeMessage(params) {
|
|
|
1586
2319
|
const policy = resolveWeChatPolicyContext({ account, cfg, chatId: message.threadId });
|
|
1587
2320
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "agent-wechat" });
|
|
1588
2321
|
const commandBody = normalizeWeChatCommandBody(message.type === 1 ? message.body ?? "" : "", { isGroup, wasMentioned });
|
|
2322
|
+
const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
|
|
2323
|
+
const resolve = core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress;
|
|
2324
|
+
const senderAdmission = (senderId) => resolveWeChatIngress({
|
|
2325
|
+
resolve,
|
|
2326
|
+
cfg,
|
|
2327
|
+
accountId: account.accountId,
|
|
2328
|
+
policy,
|
|
2329
|
+
isGroup,
|
|
2330
|
+
chatId: message.threadId,
|
|
2331
|
+
senderId,
|
|
2332
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
|
|
2333
|
+
});
|
|
1589
2334
|
const ingress = await resolveWeChatIngress({
|
|
1590
|
-
resolve
|
|
2335
|
+
resolve,
|
|
1591
2336
|
cfg,
|
|
1592
2337
|
accountId: account.accountId,
|
|
1593
2338
|
policy,
|
|
@@ -1595,33 +2340,58 @@ async function dispatchNativeMessage(params) {
|
|
|
1595
2340
|
chatId: message.threadId,
|
|
1596
2341
|
senderId: message.senderId,
|
|
1597
2342
|
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => []),
|
|
1598
|
-
command: { allowTextCommands, hasControlCommand
|
|
2343
|
+
command: { allowTextCommands, hasControlCommand },
|
|
1599
2344
|
mention: { wasMentioned, allowTextCommands }
|
|
1600
2345
|
});
|
|
1601
2346
|
const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
|
|
1602
2347
|
if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
|
|
1603
|
-
|
|
2348
|
+
if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
|
|
2349
|
+
state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg), revision);
|
|
2350
|
+
return "ignored";
|
|
2351
|
+
}
|
|
2352
|
+
state.update(message.id, { state: "ignored" }, revision);
|
|
1604
2353
|
return "ignored";
|
|
1605
2354
|
}
|
|
1606
2355
|
let preparedMedia;
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
state.
|
|
1611
|
-
|
|
1612
|
-
|
|
2356
|
+
const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
|
|
2357
|
+
const assertCurrent = () => {
|
|
2358
|
+
params.signal?.throwIfAborted();
|
|
2359
|
+
if (!state.isCurrent(message, revision)) throw new NativeInputInvalidated();
|
|
2360
|
+
};
|
|
2361
|
+
const recheck = async (inputs) => {
|
|
2362
|
+
assertCurrent();
|
|
2363
|
+
await recheckNativeInputs(client, state, inputs);
|
|
2364
|
+
assertCurrent();
|
|
2365
|
+
};
|
|
2366
|
+
await recheck([message]);
|
|
2367
|
+
preparedMedia = await (params.prepareAttachment ?? prepareNativeAttachment)({ client, account, message, core, cfg, allowQuotedMessage, assertCurrent });
|
|
1613
2368
|
if (preparedMedia.pending) return "pending";
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2369
|
+
preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
|
|
2370
|
+
await recheck([message]);
|
|
2371
|
+
const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
|
|
2372
|
+
const admitted = [];
|
|
2373
|
+
for (const item of buffered) {
|
|
2374
|
+
if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
|
|
2375
|
+
}
|
|
2376
|
+
const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
|
|
2377
|
+
await (params.prepareAttachment ?? prepareNativeAttachment)({
|
|
2378
|
+
client,
|
|
2379
|
+
account,
|
|
2380
|
+
message: historical,
|
|
2381
|
+
core,
|
|
2382
|
+
cfg,
|
|
2383
|
+
allowQuotedMessage,
|
|
2384
|
+
assertCurrent: () => {
|
|
2385
|
+
assertCurrent();
|
|
2386
|
+
if (!state.isCurrent(historical)) throw new NativeInputInvalidated();
|
|
2387
|
+
}
|
|
2388
|
+
}),
|
|
2389
|
+
core,
|
|
2390
|
+
cfg
|
|
2391
|
+
));
|
|
2392
|
+
await recheck([message, ...admitted]);
|
|
2393
|
+
const context = buildNativeContext(history, message, preparedMedia);
|
|
2394
|
+
const inputBody = context.inputBody;
|
|
1625
2395
|
const route = core.channel.routing.resolveAgentRoute({
|
|
1626
2396
|
cfg,
|
|
1627
2397
|
channel: "agent-wechat",
|
|
@@ -1649,14 +2419,17 @@ async function dispatchNativeMessage(params) {
|
|
|
1649
2419
|
SenderId: message.senderId,
|
|
1650
2420
|
Provider: "agent-wechat",
|
|
1651
2421
|
Surface: "agent-wechat",
|
|
1652
|
-
MessageSid:
|
|
2422
|
+
MessageSid: nativeSourceMessageSid(message),
|
|
1653
2423
|
WasMentioned: isGroup ? wasMentioned : void 0,
|
|
2424
|
+
Attachments: context.attachments,
|
|
2425
|
+
InboundHistory: context.history,
|
|
1654
2426
|
CommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized(commandBody, cfg) ? ingress.commandAccess.authorized : void 0,
|
|
1655
2427
|
OriginatingChannel: "agent-wechat",
|
|
1656
2428
|
OriginatingTo: `wechat:${message.threadId}`,
|
|
1657
|
-
...
|
|
2429
|
+
...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
|
|
1658
2430
|
});
|
|
1659
2431
|
await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
|
|
2432
|
+
await recheck([message, ...admitted]);
|
|
1660
2433
|
const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
|
|
1661
2434
|
const { onModelSelected, ...prefixOptions } = pipeline;
|
|
1662
2435
|
const dispatchManifest = nativeDispatchManifest({
|
|
@@ -1666,11 +2439,14 @@ async function dispatchNativeMessage(params) {
|
|
|
1666
2439
|
agentId: route.agentId,
|
|
1667
2440
|
channelAccountId: account.accountId,
|
|
1668
2441
|
policy,
|
|
1669
|
-
assets:
|
|
2442
|
+
assets: context.assets,
|
|
2443
|
+
history: admitted
|
|
1670
2444
|
});
|
|
1671
|
-
|
|
1672
|
-
|
|
2445
|
+
assertCurrent();
|
|
2446
|
+
state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
|
|
2447
|
+
const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
|
|
1673
2448
|
if (callback) trackNativePreparedInput(dispatchManifest, callback);
|
|
2449
|
+
const beforeSend = () => recheck([message, ...admitted]);
|
|
1674
2450
|
let failed = false;
|
|
1675
2451
|
let delivery = 0;
|
|
1676
2452
|
try {
|
|
@@ -1680,17 +2456,22 @@ async function dispatchNativeMessage(params) {
|
|
|
1680
2456
|
dispatcherOptions: {
|
|
1681
2457
|
...prefixOptions,
|
|
1682
2458
|
deliver: async (payload) => {
|
|
2459
|
+
params.signal?.throwIfAborted();
|
|
2460
|
+
if (state.get(message.id)?.invalidatedByRecall) return;
|
|
2461
|
+
await beforeSend();
|
|
1683
2462
|
const urls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
1684
2463
|
if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
|
|
1685
2464
|
if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
|
|
1686
|
-
for (const
|
|
1687
|
-
const key = createHash8("sha256").update(`${
|
|
1688
|
-
await sendNativeAttachment(client, account, message.threadId,
|
|
2465
|
+
for (const url2 of urls) {
|
|
2466
|
+
const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
|
|
2467
|
+
await sendNativeAttachment(client, account, message.threadId, url2, payload.audioAsVoice === true, key, beforeSend);
|
|
1689
2468
|
}
|
|
1690
2469
|
if (payload.text) {
|
|
1691
|
-
|
|
2470
|
+
await beforeSend();
|
|
2471
|
+
const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
|
|
1692
2472
|
await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
|
|
1693
2473
|
}
|
|
2474
|
+
if (urls.length || payload.text) params.onDelivered?.();
|
|
1694
2475
|
},
|
|
1695
2476
|
onError: () => {
|
|
1696
2477
|
failed = true;
|
|
@@ -1702,14 +2483,34 @@ async function dispatchNativeMessage(params) {
|
|
|
1702
2483
|
// and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
|
|
1703
2484
|
replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
|
|
1704
2485
|
});
|
|
1705
|
-
|
|
1706
|
-
state.
|
|
2486
|
+
params.signal?.throwIfAborted();
|
|
2487
|
+
if (state.get(message.id)?.revision !== revision || state.get(message.id)?.dispatchManifest?.runId !== dispatchManifest.runId) return state.isRecalled(message) ? "ignored" : "needs_review";
|
|
2488
|
+
if (state.get(message.id)?.invalidatedByRecall) return state.isRecalled(message) ? "ignored" : "needs_review";
|
|
2489
|
+
const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
|
|
2490
|
+
state.update(message.id, {
|
|
2491
|
+
state: failed ? "needs_review" : "completed",
|
|
2492
|
+
readPending,
|
|
2493
|
+
handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
|
|
2494
|
+
}, revision);
|
|
1707
2495
|
return failed ? "needs_review" : "completed";
|
|
1708
2496
|
} catch {
|
|
1709
|
-
state.
|
|
2497
|
+
if (state.isRecalled(message)) return "ignored";
|
|
2498
|
+
state.update(message.id, { state: "needs_review" }, revision);
|
|
1710
2499
|
return "needs_review";
|
|
1711
2500
|
}
|
|
1712
2501
|
}
|
|
2502
|
+
async function synchronizeNativeEvents(client, accountId, state) {
|
|
2503
|
+
for (let index = 0; index < 100; index++) {
|
|
2504
|
+
const page = await client.events(accountId, state.cursor, 200);
|
|
2505
|
+
if (!page.items.length) return;
|
|
2506
|
+
const before = state.cursor;
|
|
2507
|
+
for (const event of page.items) await acceptNativeEvent(client, accountId, state, event);
|
|
2508
|
+
if (state.cursor === before) throw Error("Native recovery event cursor did not advance");
|
|
2509
|
+
await client.ack(accountId, "openclaw", state.cursor);
|
|
2510
|
+
if (page.items.length < 200) return;
|
|
2511
|
+
}
|
|
2512
|
+
throw Error("Native recovery backlog remains; no model input was dispatched");
|
|
2513
|
+
}
|
|
1713
2514
|
function startNativeReadPump(task, failed, intervalMs = 5e3) {
|
|
1714
2515
|
let active2 = false, stopped = false;
|
|
1715
2516
|
const timer = setInterval(() => {
|
|
@@ -1737,23 +2538,35 @@ async function startNativeMonitor(opts) {
|
|
|
1737
2538
|
const core = getWeChatRuntime();
|
|
1738
2539
|
let backoff = 250;
|
|
1739
2540
|
let readTask;
|
|
1740
|
-
const flushRead = () =>
|
|
1741
|
-
|
|
1742
|
-
|
|
2541
|
+
const flushRead = () => {
|
|
2542
|
+
if (native.readPolicy === "never") return Promise.resolve();
|
|
2543
|
+
return readTask ??= projectNativeRead(host2.client, native.accountId, state).finally(() => {
|
|
2544
|
+
readTask = void 0;
|
|
2545
|
+
});
|
|
2546
|
+
};
|
|
1743
2547
|
const process2 = async () => {
|
|
1744
2548
|
const cfg = core.config.current();
|
|
1745
2549
|
const account = resolveWeChatAccount(cfg, opts.account.accountId) ?? opts.account;
|
|
1746
2550
|
if (account.backend !== "native" || JSON.stringify(account.native) !== JSON.stringify(native)) throw new Error("Native service configuration changed; restart required before processing");
|
|
1747
2551
|
for (const item of state.items().filter((value) => value.state === "received")) {
|
|
1748
|
-
await dispatchNativeMessage({
|
|
2552
|
+
await dispatchNativeMessage({
|
|
2553
|
+
client: host2.client,
|
|
2554
|
+
state,
|
|
2555
|
+
account,
|
|
2556
|
+
message: item.message,
|
|
2557
|
+
cfg,
|
|
2558
|
+
core,
|
|
2559
|
+
signal: opts.abortSignal,
|
|
2560
|
+
onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
|
|
2561
|
+
});
|
|
1749
2562
|
}
|
|
1750
2563
|
try {
|
|
1751
2564
|
await flushRead();
|
|
1752
2565
|
} catch {
|
|
1753
|
-
opts.log?.error?.("Native read projection is pending;
|
|
2566
|
+
opts.log?.error?.("Native read projection is pending; thread read receipt retained for retry");
|
|
1754
2567
|
}
|
|
1755
|
-
const needsReview = state.items().filter((value) => value.state === "needs_review").length;
|
|
1756
|
-
const handoffUnconfirmed = native.readPolicy
|
|
2568
|
+
const needsReview = state.items().filter((value) => value.state === "needs_review" || value.phoneReadState === "needs_review" || value.phoneReadState === "rejected").length;
|
|
2569
|
+
const handoffUnconfirmed = native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed);
|
|
1757
2570
|
const accountStatus = await host2.client.account(native.accountId);
|
|
1758
2571
|
opts.setStatus({
|
|
1759
2572
|
accountId: account.accountId,
|
|
@@ -1764,29 +2577,42 @@ async function startNativeMonitor(opts) {
|
|
|
1764
2577
|
handoffUnconfirmed
|
|
1765
2578
|
});
|
|
1766
2579
|
};
|
|
1767
|
-
|
|
2580
|
+
let processing;
|
|
2581
|
+
let processAgain = false;
|
|
2582
|
+
let intakeReady = false;
|
|
2583
|
+
const scheduleProcess = () => {
|
|
2584
|
+
processAgain = true;
|
|
2585
|
+
if (processing || opts.abortSignal.aborted || !intakeReady) return;
|
|
2586
|
+
processing = (async () => {
|
|
2587
|
+
do {
|
|
2588
|
+
processAgain = false;
|
|
2589
|
+
await process2();
|
|
2590
|
+
} while (processAgain && !opts.abortSignal.aborted);
|
|
2591
|
+
})().catch(() => opts.log?.error?.("Native input processing was interrupted; durable custody was retained")).finally(() => {
|
|
2592
|
+
processing = void 0;
|
|
2593
|
+
});
|
|
2594
|
+
};
|
|
1768
2595
|
const stopReadPump = startNativeReadPump(async () => {
|
|
1769
2596
|
await flushRead();
|
|
2597
|
+
scheduleProcess();
|
|
1770
2598
|
if (!opts.abortSignal.aborted) opts.setStatus({
|
|
1771
2599
|
accountId: opts.account.accountId,
|
|
1772
|
-
handoffUnconfirmed: native.readPolicy
|
|
2600
|
+
handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
|
|
1773
2601
|
});
|
|
1774
|
-
}, () => opts.log?.error?.("Native read projection remains pending;
|
|
2602
|
+
}, () => opts.log?.error?.("Native read projection remains pending; thread read receipts retained"));
|
|
1775
2603
|
try {
|
|
1776
2604
|
while (!opts.abortSignal.aborted) {
|
|
1777
2605
|
try {
|
|
1778
2606
|
if (state.cursor !== "0") await host2.client.ack(native.accountId, "openclaw", state.cursor);
|
|
1779
|
-
|
|
2607
|
+
intakeReady = false;
|
|
2608
|
+
await synchronizeNativeEvents(host2.client, native.accountId, state);
|
|
2609
|
+
intakeReady = true;
|
|
2610
|
+
scheduleProcess();
|
|
1780
2611
|
for await (const event of host2.client.watch(native.accountId, state.cursor, opts.abortSignal)) {
|
|
1781
|
-
|
|
1782
|
-
if (event.
|
|
1783
|
-
if (typeof event.data.messageId !== "string") throw new Error("Native committed message identity is missing; cursor retained");
|
|
1784
|
-
message = await host2.client.message(native.accountId, event.data.messageId);
|
|
1785
|
-
if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
|
|
1786
|
-
}
|
|
1787
|
-
state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
|
|
2612
|
+
const message = await acceptNativeEvent(host2.client, native.accountId, state, event);
|
|
2613
|
+
if (message?.direction === "incoming" && event.data.origin === "live" && !nativeRecallInfo(message)) opts.setStatus({ accountId: opts.account.accountId, lastInboundAt: Date.now() });
|
|
1788
2614
|
await host2.client.ack(native.accountId, "openclaw", state.cursor);
|
|
1789
|
-
|
|
2615
|
+
scheduleProcess();
|
|
1790
2616
|
backoff = 250;
|
|
1791
2617
|
}
|
|
1792
2618
|
if (!opts.abortSignal.aborted) throw new Error("Native event stream closed");
|
|
@@ -1856,11 +2682,17 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
|
|
|
1856
2682
|
}
|
|
1857
2683
|
return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
|
|
1858
2684
|
}
|
|
1859
|
-
async function sendNativeText(account,
|
|
2685
|
+
async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
|
|
1860
2686
|
const native = account.native;
|
|
1861
2687
|
const { client } = await ensureNativeHost(native);
|
|
1862
|
-
const send = await submitNativeText(client, native.accountId,
|
|
1863
|
-
return
|
|
2688
|
+
const send = await submitNativeText(client, native.accountId, threadId2, text3, key);
|
|
2689
|
+
return nativeOutboundDelivery(
|
|
2690
|
+
client,
|
|
2691
|
+
native.accountId,
|
|
2692
|
+
threadId2,
|
|
2693
|
+
send.localMessageId ? [send.localMessageId] : [],
|
|
2694
|
+
{ sendId: send.id, state: send.state, serverIds: send.serverId ? [send.serverId] : void 0 }
|
|
2695
|
+
);
|
|
1864
2696
|
}
|
|
1865
2697
|
function createNativeLoginTool(account) {
|
|
1866
2698
|
return {
|
|
@@ -1887,10 +2719,10 @@ function createNativeLoginTool(account) {
|
|
|
1887
2719
|
}
|
|
1888
2720
|
|
|
1889
2721
|
// src/channel.ts
|
|
1890
|
-
async function sendWeChatText(cfg, to,
|
|
2722
|
+
async function sendWeChatText(cfg, to, text3) {
|
|
1891
2723
|
const account = resolveWeChatAccount(cfg);
|
|
1892
2724
|
if (!account?.serverUrl) throw new Error("No native daemon configured");
|
|
1893
|
-
return sendNativeText(account, to,
|
|
2725
|
+
return sendNativeText(account, to, text3);
|
|
1894
2726
|
}
|
|
1895
2727
|
var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
|
|
1896
2728
|
id: "agent-wechat",
|
|
@@ -1900,13 +2732,13 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
|
|
|
1900
2732
|
messageSendingHooks: true
|
|
1901
2733
|
},
|
|
1902
2734
|
outbound: {
|
|
1903
|
-
sendText: async ({ cfg, to, text }) => ({
|
|
2735
|
+
sendText: async ({ cfg, to, text: text3 }) => ({
|
|
1904
2736
|
channel: "agent-wechat",
|
|
1905
|
-
|
|
2737
|
+
...await sendWeChatText(cfg, to, text3)
|
|
1906
2738
|
}),
|
|
1907
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
|
|
2739
|
+
sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
|
|
1908
2740
|
channel: "agent-wechat",
|
|
1909
|
-
|
|
2741
|
+
...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
|
|
1910
2742
|
})
|
|
1911
2743
|
}
|
|
1912
2744
|
});
|
|
@@ -2029,7 +2861,7 @@ var wechatPlugin = {
|
|
|
2029
2861
|
},
|
|
2030
2862
|
// ---- Mention adapter ----
|
|
2031
2863
|
mentions: {
|
|
2032
|
-
stripMentions: ({ text, ctx }) => normalizeWeChatCommandBody(
|
|
2864
|
+
stripMentions: ({ text: text3, ctx }) => normalizeWeChatCommandBody(text3, {
|
|
2033
2865
|
isGroup: ctx.ChatType === "group",
|
|
2034
2866
|
wasMentioned: ctx.WasMentioned === true
|
|
2035
2867
|
})
|
|
@@ -2048,13 +2880,13 @@ var wechatPlugin = {
|
|
|
2048
2880
|
// ---- Outbound adapter for OpenClaw hosts using the outbound hook ----
|
|
2049
2881
|
outbound: {
|
|
2050
2882
|
deliveryMode: "direct",
|
|
2051
|
-
sendText: async ({ cfg, to, text }) => ({
|
|
2883
|
+
sendText: async ({ cfg, to, text: text3 }) => ({
|
|
2052
2884
|
channel: "agent-wechat",
|
|
2053
|
-
|
|
2885
|
+
...await sendWeChatText(cfg, to, text3)
|
|
2054
2886
|
}),
|
|
2055
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
|
|
2887
|
+
sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
|
|
2056
2888
|
channel: "agent-wechat",
|
|
2057
|
-
|
|
2889
|
+
...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
|
|
2058
2890
|
})
|
|
2059
2891
|
},
|
|
2060
2892
|
message: wechatMessageAdapter,
|
|
@@ -2235,9 +3067,9 @@ function createNativeFacade(config, dependencies = {}) {
|
|
|
2235
3067
|
try {
|
|
2236
3068
|
const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
|
|
2237
3069
|
const headers = new Headers();
|
|
2238
|
-
for (const
|
|
2239
|
-
const value = req.headers[
|
|
2240
|
-
if (typeof value === "string") headers.set(
|
|
3070
|
+
for (const name2 of ["content-type", "accept", "range", "if-none-match", "if-range", "last-event-id", "idempotency-key"]) {
|
|
3071
|
+
const value = req.headers[name2];
|
|
3072
|
+
if (typeof value === "string") headers.set(name2, value);
|
|
2241
3073
|
}
|
|
2242
3074
|
const length = req.headers["content-length"];
|
|
2243
3075
|
if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
|
|
@@ -2261,9 +3093,9 @@ function createNativeFacade(config, dependencies = {}) {
|
|
|
2261
3093
|
response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
|
|
2262
3094
|
}
|
|
2263
3095
|
res.statusCode = response.status;
|
|
2264
|
-
for (const
|
|
2265
|
-
const value = response.headers.get(
|
|
2266
|
-
if (value) res.setHeader(
|
|
3096
|
+
for (const name2 of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "cache-control", "content-disposition", "x-content-type-options"]) {
|
|
3097
|
+
const value = response.headers.get(name2);
|
|
3098
|
+
if (value) res.setHeader(name2, value);
|
|
2267
3099
|
}
|
|
2268
3100
|
res.setHeader("x-content-type-options", "nosniff");
|
|
2269
3101
|
const reader = response.body?.getReader();
|
|
@@ -2310,6 +3142,528 @@ function registerNativeFacade(api, config) {
|
|
|
2310
3142
|
return handler.stop;
|
|
2311
3143
|
}
|
|
2312
3144
|
|
|
3145
|
+
// src/native-history-tools.ts
|
|
3146
|
+
import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress2 } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
3147
|
+
import { imageResultFromFile } from "openclaw/plugin-sdk/channel-actions";
|
|
3148
|
+
var pageSchema = { limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, cursor: { type: "string", maxLength: 8192, description: "Opaque nextCursor from the same tool and query; do not invent one." } };
|
|
3149
|
+
var names = ["wechat_history", "wechat_search", "wechat_message", "wechat_media"];
|
|
3150
|
+
var archiveNames = ["wechat_archive_threads", "wechat_archive_search", "wechat_archive_history", "wechat_archive_message", "wechat_archive_media"];
|
|
3151
|
+
var messageIdSchema = { type: "string", minLength: 1, maxLength: 4096 };
|
|
3152
|
+
var qualities = ["best", "original", "standard", "thumbnail", "normal", "encoded"];
|
|
3153
|
+
function object(value) {
|
|
3154
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
|
|
3155
|
+
return value;
|
|
3156
|
+
}
|
|
3157
|
+
function string(value, name2, max = 4096) {
|
|
3158
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${name2}`);
|
|
3159
|
+
return value.trim();
|
|
3160
|
+
}
|
|
3161
|
+
function threadId(value) {
|
|
3162
|
+
return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
|
|
3163
|
+
}
|
|
3164
|
+
function messageResult(message, retrievalTool = "wechat_media") {
|
|
3165
|
+
if (nativeRecallInfo(message)) return {
|
|
3166
|
+
id: message.id,
|
|
3167
|
+
threadId: message.threadId,
|
|
3168
|
+
serverId: message.serverId,
|
|
3169
|
+
direction: message.direction,
|
|
3170
|
+
type: 1e4,
|
|
3171
|
+
body: "[Message recalled]",
|
|
3172
|
+
status: "recalled",
|
|
3173
|
+
recall: nativeRecallInfo(message)
|
|
3174
|
+
};
|
|
3175
|
+
const isQuote = message.type === 49 && message.appMessageType === 57;
|
|
3176
|
+
const isForward = message.type === 49 && message.appMessageType === 19;
|
|
3177
|
+
const isLink = message.type === 49 && [3, 4, 5].includes(message.appMessageType ?? 0) && !!message.link;
|
|
3178
|
+
const kind2 = isQuote ? "quote" : isForward ? "forwarded_history" : isLink ? "link_preview" : message.type === 3 ? "image" : message.type === 47 ? "sticker" : message.type === 34 ? "voice" : message.type === 43 ? "video" : message.type === 49 && (message.appMessageType == null || message.appMessageType === 6) ? "file" : "unsupported";
|
|
3179
|
+
const body = message.type === 1 || isForward || isLink ? message.body ?? "" : isQuote ? `${message.body ?? ""}
|
|
3180
|
+
[Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
|
|
3181
|
+
return {
|
|
3182
|
+
id: message.id,
|
|
3183
|
+
threadId: message.threadId,
|
|
3184
|
+
serverId: message.serverId,
|
|
3185
|
+
senderId: message.senderId,
|
|
3186
|
+
direction: message.direction,
|
|
3187
|
+
type: message.type,
|
|
3188
|
+
appMessageType: message.appMessageType,
|
|
3189
|
+
quote: message.quote,
|
|
3190
|
+
link: isLink ? message.link : void 0,
|
|
3191
|
+
createdAt: message.createdAt,
|
|
3192
|
+
body: body.slice(0, 4e3),
|
|
3193
|
+
bodyTruncated: body.length > 4e3,
|
|
3194
|
+
attachment: message.type !== 1 && !isForward && kind2 !== "unsupported" ? { kind: kind2, retrievalTool, messageId: message.id } : void 0
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
function createNativeHistoryTools(context, dependencies = {}) {
|
|
3198
|
+
return createLookupTools(context, dependencies, false);
|
|
3199
|
+
}
|
|
3200
|
+
function createNativeArchiveTools(context, dependencies = {}) {
|
|
3201
|
+
return createLookupTools(context, dependencies, true);
|
|
3202
|
+
}
|
|
3203
|
+
function createLookupTools(context, dependencies, archive) {
|
|
3204
|
+
const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
|
|
3205
|
+
if (!currentConfig()) return [];
|
|
3206
|
+
const selected = resolveWeChatAccount(currentConfig());
|
|
3207
|
+
if (!selected?.native || !selected.enabled) return [];
|
|
3208
|
+
const configurationKey = nativeConfigurationKey(selected.native);
|
|
3209
|
+
if (!archive && context.messageChannel !== "agent-wechat") return [];
|
|
3210
|
+
if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
|
|
3211
|
+
if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
3212
|
+
let currentThread;
|
|
3213
|
+
let requesterSenderId;
|
|
3214
|
+
const target = context.deliveryContext?.channel === "agent-wechat" ? context.deliveryContext.to : void 0;
|
|
3215
|
+
if (!archive) {
|
|
3216
|
+
try {
|
|
3217
|
+
if (context.nativeChannelId) currentThread = threadId(context.nativeChannelId);
|
|
3218
|
+
if (target) {
|
|
3219
|
+
const deliveryThread = threadId(target);
|
|
3220
|
+
if (currentThread && currentThread !== deliveryThread) return [];
|
|
3221
|
+
currentThread ??= deliveryThread;
|
|
3222
|
+
}
|
|
3223
|
+
requesterSenderId = string(context.requesterSenderId, "requester sender ID");
|
|
3224
|
+
} catch {
|
|
3225
|
+
return [];
|
|
3226
|
+
}
|
|
3227
|
+
if (!currentThread) return [];
|
|
3228
|
+
}
|
|
3229
|
+
const selectedId = selected.native.accountId;
|
|
3230
|
+
const tool = (name2, action, description, properties, required = []) => ({
|
|
3231
|
+
name: name2,
|
|
3232
|
+
label: name2.replaceAll("_", " "),
|
|
3233
|
+
description,
|
|
3234
|
+
// The pinned host's tool_call wrapper JSON-serializes image blocks. Its
|
|
3235
|
+
// public direct-only surface preserves actual image content for vision.
|
|
3236
|
+
...action === "media" ? { catalogMode: "direct-only" } : {},
|
|
3237
|
+
parameters: { type: "object", additionalProperties: false, properties, required },
|
|
3238
|
+
execute: async (_callId, input, signal) => {
|
|
3239
|
+
const args = object(input);
|
|
3240
|
+
const guard = () => {
|
|
3241
|
+
signal?.throwIfAborted();
|
|
3242
|
+
context.assertInvocationCurrent?.();
|
|
3243
|
+
const cfg2 = currentConfig();
|
|
3244
|
+
const account2 = resolveWeChatAccount(cfg2);
|
|
3245
|
+
if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId || nativeConfigurationKey(account2.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
|
|
3246
|
+
return { cfg: cfg2, account: account2, native: account2.native };
|
|
3247
|
+
};
|
|
3248
|
+
const { cfg, account, native } = guard();
|
|
3249
|
+
if (Object.keys(args).some((key) => !Object.hasOwn(properties, key))) throw Error(archive ? "Unknown archive lookup argument" : "Unknown lookup argument; WeChat history is restricted to the current conversation");
|
|
3250
|
+
const limit = args.limit ?? 20;
|
|
3251
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw Error("limit must be 1\u201350");
|
|
3252
|
+
const cursor = args.cursor === void 0 ? void 0 : string(args.cursor, "cursor", 8192);
|
|
3253
|
+
const query = args.query === void 0 ? void 0 : string(args.query, "query", 256);
|
|
3254
|
+
if (query && Buffer.byteLength(query) > 256) throw Error("Search query exceeds 256 UTF-8 bytes");
|
|
3255
|
+
if (action === "search" && !query) throw Error("A non-empty search query is required");
|
|
3256
|
+
const conversation = archive ? args.threadId === void 0 ? void 0 : threadId(args.threadId) : currentThread;
|
|
3257
|
+
if (action === "history" && !conversation) throw Error("A thread ID is required");
|
|
3258
|
+
const messageId = Object.hasOwn(properties, "messageId") ? string(args.messageId, "message ID") : void 0;
|
|
3259
|
+
if (args.download !== void 0 && typeof args.download !== "boolean") throw Error("download must be a boolean");
|
|
3260
|
+
const quality = args.quality ?? "best";
|
|
3261
|
+
if (!qualities.includes(quality)) throw Error("Invalid media quality");
|
|
3262
|
+
const core = dependencies.core ?? getWeChatRuntime();
|
|
3263
|
+
const allowed = async (sender) => {
|
|
3264
|
+
if (archive) return true;
|
|
3265
|
+
const { cfg: cfg2, account: account2 } = guard();
|
|
3266
|
+
const result2 = await resolveWeChatIngress({
|
|
3267
|
+
resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress2,
|
|
3268
|
+
cfg: cfg2,
|
|
3269
|
+
accountId: account2.accountId,
|
|
3270
|
+
policy: resolveWeChatPolicyContext({ account: account2, cfg: cfg2, chatId: conversation }),
|
|
3271
|
+
isGroup: conversation.endsWith("@chatroom"),
|
|
3272
|
+
chatId: conversation,
|
|
3273
|
+
senderId: sender,
|
|
3274
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account2.accountId, env: process.env }).catch(() => [])
|
|
3275
|
+
});
|
|
3276
|
+
return result2.ingress.admission === "dispatch";
|
|
3277
|
+
};
|
|
3278
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
3279
|
+
guard();
|
|
3280
|
+
const { client } = await (dependencies.host ?? ensureNativeHost)(native);
|
|
3281
|
+
guard();
|
|
3282
|
+
const accountId = native.accountId;
|
|
3283
|
+
let result;
|
|
3284
|
+
if (action === "threads") {
|
|
3285
|
+
const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
|
|
3286
|
+
const items = [];
|
|
3287
|
+
for (const thread3 of page.items) {
|
|
3288
|
+
if (isNativeTombstone(thread3)) continue;
|
|
3289
|
+
if (thread3.accountId !== accountId) throw Error("Thread response crosses an account boundary");
|
|
3290
|
+
items.push({ id: thread3.id, peerId: thread3.peerId, kind: thread3.kind, displayName: thread3.displayName, unreadCount: thread3.unreadCount });
|
|
3291
|
+
}
|
|
3292
|
+
result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
|
|
3293
|
+
} else if (action === "message" || action === "media") {
|
|
3294
|
+
const message = await client.message(accountId, messageId);
|
|
3295
|
+
guard();
|
|
3296
|
+
if (!await allowed(requesterSenderId)) throw Error("WeChat conversation access is no longer allowed");
|
|
3297
|
+
if (message.id !== messageId || message.accountId !== accountId || !archive && message.threadId !== conversation || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("Message is outside the permitted conversation or sender policy");
|
|
3298
|
+
result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
|
|
3299
|
+
if (action === "media") {
|
|
3300
|
+
const checkRecall = async () => {
|
|
3301
|
+
guard();
|
|
3302
|
+
const latest = await client.message(accountId, messageId);
|
|
3303
|
+
guard();
|
|
3304
|
+
if (latest.id !== message.id || latest.accountId !== accountId || latest.threadId !== message.threadId || latest.serverId !== message.serverId) throw Error("Native media message identity changed");
|
|
3305
|
+
if (nativeRecallInfo(latest)) throw new NativeApiError(410, "message_recalled", false);
|
|
3306
|
+
if (message.quote) {
|
|
3307
|
+
const quote = await client.quotedMessage(accountId, message.id);
|
|
3308
|
+
guard();
|
|
3309
|
+
if (quote.state === "recalled" || quote.message && nativeRecallInfo(quote.message)) throw new NativeApiError(410, "message_recalled", false);
|
|
3310
|
+
}
|
|
3311
|
+
};
|
|
3312
|
+
let prepared;
|
|
3313
|
+
try {
|
|
3314
|
+
prepared = await prepareNativeAttachment({
|
|
3315
|
+
client,
|
|
3316
|
+
account,
|
|
3317
|
+
message,
|
|
3318
|
+
core,
|
|
3319
|
+
cfg,
|
|
3320
|
+
quality,
|
|
3321
|
+
download: args.download !== false,
|
|
3322
|
+
waitMs: 2e3,
|
|
3323
|
+
signal,
|
|
3324
|
+
assertCurrent: guard,
|
|
3325
|
+
allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
|
|
3326
|
+
beforeMediaAccess: async () => {
|
|
3327
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
3328
|
+
await checkRecall();
|
|
3329
|
+
}
|
|
3330
|
+
});
|
|
3331
|
+
} catch (error) {
|
|
3332
|
+
guard();
|
|
3333
|
+
if (!(error instanceof NativeApiError)) throw error;
|
|
3334
|
+
const details2 = {
|
|
3335
|
+
messageId: message.id,
|
|
3336
|
+
threadId: message.threadId,
|
|
3337
|
+
status: error.code === "message_recalled" ? "recalled" : error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
|
|
3338
|
+
retryable: error.retryable,
|
|
3339
|
+
reason: error.code
|
|
3340
|
+
};
|
|
3341
|
+
return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
|
|
3342
|
+
}
|
|
3343
|
+
guard();
|
|
3344
|
+
if (!await allowed(requesterSenderId) || message.direction !== "outgoing" && !await allowed(message.senderId ?? void 0)) throw Error("WeChat conversation access is no longer allowed");
|
|
3345
|
+
const details = {
|
|
3346
|
+
messageId: message.id,
|
|
3347
|
+
threadId: message.threadId,
|
|
3348
|
+
status: prepared.reason === "message_recalled" ? "recalled" : prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
|
|
3349
|
+
attachment: prepared.attachment,
|
|
3350
|
+
quote: prepared.quote,
|
|
3351
|
+
link: ["message_recalled", "media_identity_unavailable", "media_source_changed"].includes(prepared.reason ?? "") ? void 0 : message.link,
|
|
3352
|
+
variant: prepared.variant,
|
|
3353
|
+
jobId: prepared.jobId,
|
|
3354
|
+
retryable: prepared.retryable ?? false,
|
|
3355
|
+
reason: prepared.reason,
|
|
3356
|
+
body: prepared.body
|
|
3357
|
+
};
|
|
3358
|
+
const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
|
|
3359
|
+
const output = image ? await imageResultFromFile({ label: "WeChat attachment", path: image.path, extraText: JSON.stringify(details), details }) : { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
3360
|
+
guard();
|
|
3361
|
+
try {
|
|
3362
|
+
await checkRecall();
|
|
3363
|
+
} catch (error) {
|
|
3364
|
+
if (!(error instanceof NativeApiError) || error.code !== "message_recalled") throw error;
|
|
3365
|
+
const details2 = { messageId: message.id, threadId: message.threadId, status: "recalled", reason: "message_recalled", retryable: false };
|
|
3366
|
+
return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2 };
|
|
3367
|
+
}
|
|
3368
|
+
return output;
|
|
3369
|
+
}
|
|
3370
|
+
} else {
|
|
3371
|
+
const options = { limit, cursor, ...action === "search" ? { query } : {} };
|
|
3372
|
+
const page = conversation ? await client.messages(accountId, conversation, options) : await client.searchMessages(accountId, options);
|
|
3373
|
+
const items = [];
|
|
3374
|
+
for (const message of page.items) {
|
|
3375
|
+
if (isNativeTombstone(message)) continue;
|
|
3376
|
+
if (conversation && message.threadId !== conversation || message.accountId !== accountId) throw Error("WeChat history response crosses a conversation boundary");
|
|
3377
|
+
if (message.direction === "outgoing" || await allowed(message.senderId ?? void 0)) items.push(messageResult(message, archive ? "wechat_archive_media" : "wechat_media"));
|
|
3378
|
+
}
|
|
3379
|
+
result = {
|
|
3380
|
+
...page,
|
|
3381
|
+
items,
|
|
3382
|
+
scope: "local_profile",
|
|
3383
|
+
searchMode: action === "search" ? "case_insensitive_substring" : void 0,
|
|
3384
|
+
note: archive ? "Account-wide local history may be partial. These reads do not mark threads read or download attachments." : "Local history may be partial. A filtered page can be empty with a nextCursor. These reads do not mark a conversation read or download attachments."
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
guard();
|
|
3388
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
3389
|
+
}
|
|
3390
|
+
});
|
|
3391
|
+
const querySchema = { type: "string", minLength: 1, maxLength: 256 };
|
|
3392
|
+
const mediaSchema = {
|
|
3393
|
+
messageId: messageIdSchema,
|
|
3394
|
+
download: { type: "boolean", default: true, description: "Set false to use cached media only without requesting a WeChat transfer." },
|
|
3395
|
+
quality: { type: "string", enum: qualities, default: "best", description: "best uses the best cached image, normal video or decoded voice. Explicit variants are strict and may be unavailable." }
|
|
3396
|
+
};
|
|
3397
|
+
if (archive) return [
|
|
3398
|
+
tool(archiveNames[0], "threads", "List or find locally stored threads across the configured WeChat account. Optional case-insensitive query matches thread IDs and display names. Includes groups and peers, with opaque pagination; no read marking.", { query: querySchema, ...pageSchema }),
|
|
3399
|
+
tool(archiveNames[1], "search", "Search locally stored messages across every thread in the configured WeChat account, including other senders. Optional threadId narrows the search. Follow nextCursor with the same query and thread selection; no remote backfill or read marking.", { query: querySchema, threadId: messageIdSchema, ...pageSchema }, ["query"]),
|
|
3400
|
+
tool(archiveNames[2], "history", "Read the locally stored history of any thread in the configured WeChat account, newest first, without sender filtering or read marking. Use wechat_archive_threads to find thread IDs.", { threadId: messageIdSchema, ...pageSchema }, ["threadId"]),
|
|
3401
|
+
tool(archiveNames[3], "message", "Look up a stored message in any thread of the configured WeChat account by its exact opaque message ID. Use wechat_archive_media to retrieve its attachment.", { messageId: messageIdSchema }, ["messageId"]),
|
|
3402
|
+
tool(archiveNames[4], "media", "Retrieve an attachment or quoted text/image from any thread in the configured WeChat account by its message ID. Use the parent quote message ID for quotes. Returns verified managed file paths and an image preview for vision. Cached bytes first; missing media requests a bounded server-owned download unless download=false. Preserve pending/expired/unavailable results and never automatically retry retryable=false. No sends or read marking.", mediaSchema, ["messageId"])
|
|
3403
|
+
];
|
|
3404
|
+
return [
|
|
3405
|
+
tool(names[0], "history", "Read the current WeChat conversation's locally stored messages, newest first, beyond the recent inbound buffer. Follow nextCursor for older history; no remote history backfill or read marking.", { ...pageSchema }),
|
|
3406
|
+
tool(names[1], "search", "Search the current WeChat conversation's locally stored message text by case-insensitive substring, beyond buffered messages. Follow nextCursor with the same query in this conversation.", { query: querySchema, ...pageSchema }, ["query"]),
|
|
3407
|
+
tool(names[2], "message", "Look up a stored message by its exact opaque message ID in the current WeChat conversation. Media is described without downloading it; use wechat_media to retrieve its attachment. Long text is bounded.", { messageId: messageIdSchema }, ["messageId"]),
|
|
3408
|
+
tool(names[3], "media", "Retrieve a message attachment or quoted text/image from the current WeChat conversation. Use the parent quote message ID for quotes. Returns a verified local file path and an image preview for vision when applicable. Uses cached bytes first; download defaults to true and requests a bounded server-owned transfer if missing. Pending results may be checked again; never automatically retry confirmed expiry or retryable=false. Does not send messages or mark the thread read.", mediaSchema, ["messageId"])
|
|
3409
|
+
];
|
|
3410
|
+
}
|
|
3411
|
+
function registerNativeHistoryTools(api) {
|
|
3412
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeHistoryTools(context) }, { names });
|
|
3413
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// src/native-share-tool.ts
|
|
3417
|
+
import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress3 } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
3418
|
+
var name = "wechat_send_share";
|
|
3419
|
+
function text(value, label, max, multiline = false) {
|
|
3420
|
+
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > max || (multiline ? /[\u0000-\u0008\u000b-\u001f\u007f]/ : /[\u0000-\u001f\u007f]/).test(value)) throw Error(`Invalid ${label}`);
|
|
3421
|
+
return value;
|
|
3422
|
+
}
|
|
3423
|
+
function thread(value) {
|
|
3424
|
+
return text(text(value, "current conversation", 256).replace(/^(?:agent-)?wechat:(?:group:)?/i, "").trim(), "current conversation", 256);
|
|
3425
|
+
}
|
|
3426
|
+
function url(value, label) {
|
|
3427
|
+
const raw = text(value, label, 8192);
|
|
3428
|
+
const parsed = new URL(raw);
|
|
3429
|
+
if (!["https:", "http:"].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) throw Error(`Invalid ${label}`);
|
|
3430
|
+
return raw;
|
|
3431
|
+
}
|
|
3432
|
+
function createNativeShareTools(context, dependencies = {}) {
|
|
3433
|
+
const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
|
|
3434
|
+
if (!currentConfig()) return [];
|
|
3435
|
+
const selected = resolveWeChatAccount(currentConfig());
|
|
3436
|
+
if (!selected?.enabled || !selected.native || context.messageChannel !== "agent-wechat" || context.agentAccountId && context.agentAccountId !== selected.accountId || context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
3437
|
+
let conversation, requester;
|
|
3438
|
+
try {
|
|
3439
|
+
const native = context.nativeChannelId ? thread(context.nativeChannelId) : void 0;
|
|
3440
|
+
const delivery = context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.to ? thread(context.deliveryContext.to) : void 0;
|
|
3441
|
+
if (native && delivery && native !== delivery) return [];
|
|
3442
|
+
conversation = native ?? delivery;
|
|
3443
|
+
if (!conversation) return [];
|
|
3444
|
+
requester = text(context.requesterSenderId, "requester sender", 256);
|
|
3445
|
+
} catch {
|
|
3446
|
+
return [];
|
|
3447
|
+
}
|
|
3448
|
+
const configurationKey = nativeConfigurationKey(selected.native);
|
|
3449
|
+
const fields = ["title", "url", "description", "thumbnailUrl", "idempotencyKey"];
|
|
3450
|
+
return [{
|
|
3451
|
+
name,
|
|
3452
|
+
label: "Send WeChat share",
|
|
3453
|
+
description: "Send a URL share card to the current WeChat conversation, with an explicit title, URL, optional description and public thumbnail URL. Does not fetch or summarize the linked page. Use one idempotencyKey per intended share and reuse it after timeouts; never retry an uncertain send with a new key. Returns a durable send ID and state. Accepted means server acceptance; recipient rendering requires verification.",
|
|
3454
|
+
parameters: { type: "object", additionalProperties: false, required: ["title", "url", "idempotencyKey"], properties: {
|
|
3455
|
+
title: { type: "string", minLength: 1, maxLength: 16384 },
|
|
3456
|
+
url: { type: "string", minLength: 1, maxLength: 8192 },
|
|
3457
|
+
description: { type: "string", maxLength: 16384 },
|
|
3458
|
+
thumbnailUrl: { type: "string", minLength: 1, maxLength: 8192, description: "Public HTTP(S) image URL advertised as the card thumbnail; no upload or page scraping." },
|
|
3459
|
+
idempotencyKey: { type: "string", minLength: 1, maxLength: 200, description: "Stable unique key for this logical share. Reuse the same key and identical fields when inspecting/retrying this request." }
|
|
3460
|
+
} },
|
|
3461
|
+
execute: async (_callId, input, signal) => {
|
|
3462
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("Expected share arguments");
|
|
3463
|
+
const args = input;
|
|
3464
|
+
if (Object.keys(args).some((key2) => !fields.includes(key2))) throw Error("Share sending is restricted to the current conversation");
|
|
3465
|
+
const key = text(args.idempotencyKey, "idempotency key", 200);
|
|
3466
|
+
const request = {
|
|
3467
|
+
kind: "share",
|
|
3468
|
+
threadId: conversation,
|
|
3469
|
+
title: text(args.title, "title", 16384, true),
|
|
3470
|
+
url: url(args.url, "URL"),
|
|
3471
|
+
...args.description !== void 0 ? { description: args.description === "" ? "" : text(args.description, "description", 16384, true) } : {},
|
|
3472
|
+
...args.thumbnailUrl !== void 0 ? { thumbnailUrl: url(args.thumbnailUrl, "thumbnail URL") } : {}
|
|
3473
|
+
};
|
|
3474
|
+
const guard = () => {
|
|
3475
|
+
signal?.throwIfAborted();
|
|
3476
|
+
context.assertInvocationCurrent?.();
|
|
3477
|
+
const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
|
|
3478
|
+
if (!account?.enabled || !account.native || account.accountId !== selected.accountId || account.native.accountId !== selected.native.accountId || nativeConfigurationKey(account.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
|
|
3479
|
+
return { cfg, account, native: account.native };
|
|
3480
|
+
};
|
|
3481
|
+
const core = dependencies.core ?? getWeChatRuntime();
|
|
3482
|
+
const authorize = async () => {
|
|
3483
|
+
const { cfg, account } = guard();
|
|
3484
|
+
const result = await resolveWeChatIngress({
|
|
3485
|
+
resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress3,
|
|
3486
|
+
cfg,
|
|
3487
|
+
accountId: account.accountId,
|
|
3488
|
+
policy: resolveWeChatPolicyContext({ account, cfg, chatId: conversation }),
|
|
3489
|
+
isGroup: conversation.endsWith("@chatroom"),
|
|
3490
|
+
chatId: conversation,
|
|
3491
|
+
senderId: requester,
|
|
3492
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
|
|
3493
|
+
});
|
|
3494
|
+
guard();
|
|
3495
|
+
if (result.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
|
|
3496
|
+
};
|
|
3497
|
+
await authorize();
|
|
3498
|
+
const { native } = guard();
|
|
3499
|
+
const { client } = await (dependencies.host ?? ensureNativeHost)(native);
|
|
3500
|
+
await authorize();
|
|
3501
|
+
let intent;
|
|
3502
|
+
try {
|
|
3503
|
+
intent = await client.sendShare(native.accountId, request, key, signal);
|
|
3504
|
+
} catch (error) {
|
|
3505
|
+
if (!(error instanceof NativeApiError) || error.status >= 500 || error.code === "timeout") {
|
|
3506
|
+
const details2 = { threadId: conversation, idempotencyKey: key, state: "submission_unknown", note: "Inspect the send by this idempotency key. Do not resend with a new key." };
|
|
3507
|
+
return { content: [{ type: "text", text: JSON.stringify(details2) }], details: details2, isError: true };
|
|
3508
|
+
}
|
|
3509
|
+
throw error;
|
|
3510
|
+
}
|
|
3511
|
+
if (intent.accountId !== native.accountId || intent.destination !== conversation || !intent.id) throw Error("Share receipt crosses an account or conversation boundary; inspect the original idempotency key before another send");
|
|
3512
|
+
const delivery = intent.state === "accepted" && intent.localMessageId && intent.serverId ? await nativeOutboundDelivery(client, native.accountId, conversation, [intent.localMessageId], { sendId: intent.id, state: intent.state, serverIds: [intent.serverId] }) : void 0;
|
|
3513
|
+
const details = {
|
|
3514
|
+
id: intent.id,
|
|
3515
|
+
threadId: conversation,
|
|
3516
|
+
idempotencyKey: key,
|
|
3517
|
+
state: intent.state,
|
|
3518
|
+
serverId: intent.serverId,
|
|
3519
|
+
messageId: delivery?.messageId || void 0,
|
|
3520
|
+
receiptState: delivery?.meta.receiptState,
|
|
3521
|
+
reason: intent.errorCode,
|
|
3522
|
+
note: ["accepted", "sent", "server_accepted"].includes(intent.state) ? "Server accepted; recipient rendering is not confirmed." : "Inspect this send ID or reuse the identical request/key. Do not send again with a new key."
|
|
3523
|
+
};
|
|
3524
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details, isError: ["failed", "needs_review", "cancelled"].includes(intent.state) };
|
|
3525
|
+
}
|
|
3526
|
+
}];
|
|
3527
|
+
}
|
|
3528
|
+
function registerNativeShareTools(api) {
|
|
3529
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeShareTools(context) }, { names: [name] });
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
// src/native-recall-tools.ts
|
|
3533
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
3534
|
+
import { resolveStableChannelMessageIngress as resolveStableChannelMessageIngress4 } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
3535
|
+
var names2 = ["wechat_recall"];
|
|
3536
|
+
function text2(value, label) {
|
|
3537
|
+
if (typeof value !== "string" || !value.trim() || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${label}`);
|
|
3538
|
+
return value.trim();
|
|
3539
|
+
}
|
|
3540
|
+
function thread2(value) {
|
|
3541
|
+
return text2(text2(value, "conversation").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "conversation");
|
|
3542
|
+
}
|
|
3543
|
+
function nativeRecallKey(accountId, threadId2, messageId, serverId) {
|
|
3544
|
+
return `openclaw-recall-${createHash9("sha256").update(JSON.stringify([accountId, threadId2, messageId, serverId])).digest("hex")}`;
|
|
3545
|
+
}
|
|
3546
|
+
function createNativeRecallTools(context, dependencies = {}) {
|
|
3547
|
+
const currentConfig = () => context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config;
|
|
3548
|
+
if (!currentConfig()) return [];
|
|
3549
|
+
const selected = resolveWeChatAccount(currentConfig());
|
|
3550
|
+
if (!selected?.enabled || !selected.native || context.messageChannel !== "agent-wechat" || context.agentAccountId && context.agentAccountId !== selected.accountId || context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
3551
|
+
let conversation, requester;
|
|
3552
|
+
try {
|
|
3553
|
+
const nativeThread = context.nativeChannelId ? thread2(context.nativeChannelId) : void 0;
|
|
3554
|
+
const delivered = context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.to ? thread2(context.deliveryContext.to) : void 0;
|
|
3555
|
+
if (nativeThread && delivered && nativeThread !== delivered) return [];
|
|
3556
|
+
conversation = text2(nativeThread ?? delivered, "conversation");
|
|
3557
|
+
requester = text2(context.requesterSenderId, "requester");
|
|
3558
|
+
} catch {
|
|
3559
|
+
return [];
|
|
3560
|
+
}
|
|
3561
|
+
const accountId = selected.native.accountId;
|
|
3562
|
+
const configurationKey = nativeConfigurationKey(selected.native);
|
|
3563
|
+
return [{
|
|
3564
|
+
name: names2[0],
|
|
3565
|
+
label: "WeChat Recall",
|
|
3566
|
+
description: "Recall one own sent message in the current WeChat conversation by its exact opaque message ID from a verified send receipt or wechat_history, or inspect a retained recalled placeholder without another native mutation. The daemon checks eligibility. Returns the durable operation and confirmed, rejected, pending or uncertain result. Never invent IDs or resubmit an uncertain action with a new key; prior model input and completed actions cannot be erased.",
|
|
3567
|
+
parameters: { type: "object", additionalProperties: false, properties: {
|
|
3568
|
+
messageId: { type: "string", minLength: 1, maxLength: 4096, description: "Exact stored message ID in this conversation; send/job/operation IDs are not message IDs." },
|
|
3569
|
+
serverId: { type: "string", pattern: "^[1-9][0-9]{0,19}$", description: "Exact decimal server ID from the same verified receipt or history entry. The stored local message ID alone may be reused." }
|
|
3570
|
+
}, required: ["messageId", "serverId"] },
|
|
3571
|
+
execute: async (_callId, input, signal) => {
|
|
3572
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key2) => !["messageId", "serverId"].includes(key2))) throw Error("Recall is restricted to a message ID in the current conversation");
|
|
3573
|
+
const messageId = text2(input.messageId, "message ID");
|
|
3574
|
+
const serverId = text2(input.serverId, "server ID");
|
|
3575
|
+
if (!/^[1-9]\d{0,19}$/.test(serverId) || BigInt(serverId) > 18446744073709551615n) throw Error("Invalid exact server message ID");
|
|
3576
|
+
const guard = () => {
|
|
3577
|
+
signal?.throwIfAborted();
|
|
3578
|
+
context.assertInvocationCurrent?.();
|
|
3579
|
+
const cfg = currentConfig(), account = resolveWeChatAccount(cfg);
|
|
3580
|
+
if (!account?.enabled || !account.native || account.accountId !== selected.accountId || account.native.accountId !== accountId || nativeConfigurationKey(account.native) !== configurationKey) throw Error("WeChat account configuration changed; start a new turn");
|
|
3581
|
+
return { cfg, account, native: account.native };
|
|
3582
|
+
};
|
|
3583
|
+
const allowed = async () => {
|
|
3584
|
+
const { cfg, account } = guard();
|
|
3585
|
+
const core = dependencies.core ?? getWeChatRuntime();
|
|
3586
|
+
const ingress = await resolveWeChatIngress({
|
|
3587
|
+
resolve: core.channel.inbound?.ingress?.resolveStable ?? resolveStableChannelMessageIngress4,
|
|
3588
|
+
cfg,
|
|
3589
|
+
accountId: account.accountId,
|
|
3590
|
+
policy: resolveWeChatPolicyContext({ account, cfg, chatId: conversation }),
|
|
3591
|
+
isGroup: conversation.endsWith("@chatroom"),
|
|
3592
|
+
chatId: conversation,
|
|
3593
|
+
senderId: requester,
|
|
3594
|
+
readStoreAllowFrom: async () => core.channel.pairing.readAllowFromStore({ channel: "agent-wechat", accountId: account.accountId, env: process.env }).catch(() => [])
|
|
3595
|
+
});
|
|
3596
|
+
guard();
|
|
3597
|
+
if (ingress.ingress.admission !== "dispatch") throw Error("WeChat conversation access is no longer allowed");
|
|
3598
|
+
};
|
|
3599
|
+
await allowed();
|
|
3600
|
+
const { native } = guard();
|
|
3601
|
+
const { client } = await (dependencies.host ?? ensureNativeHost)(native);
|
|
3602
|
+
guard();
|
|
3603
|
+
const message = await client.message(accountId, messageId);
|
|
3604
|
+
guard();
|
|
3605
|
+
const retainedRecall = !!nativeRecallInfo(message);
|
|
3606
|
+
if (message.id !== messageId || message.accountId !== accountId || message.threadId !== conversation || message.direction !== "outgoing" && !retainedRecall) throw Error("Recall target is outside this conversation or was not sent by this account");
|
|
3607
|
+
if (message.serverId !== serverId) throw Error("The exact server identity does not match this receipt; the local message identity may have been reused");
|
|
3608
|
+
await allowed();
|
|
3609
|
+
guard();
|
|
3610
|
+
const key = nativeRecallKey(accountId, conversation, messageId, serverId);
|
|
3611
|
+
let operation;
|
|
3612
|
+
let operationId;
|
|
3613
|
+
const result = (status2, extra = {}) => {
|
|
3614
|
+
const details = {
|
|
3615
|
+
messageId,
|
|
3616
|
+
serverId,
|
|
3617
|
+
threadId: conversation,
|
|
3618
|
+
accountId,
|
|
3619
|
+
action: retainedRecall ? "inspect_recalled" : "recall",
|
|
3620
|
+
idempotencyKey: key,
|
|
3621
|
+
operationId,
|
|
3622
|
+
operationState: operation?.state,
|
|
3623
|
+
status: status2,
|
|
3624
|
+
retryable: false,
|
|
3625
|
+
...extra
|
|
3626
|
+
};
|
|
3627
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
3628
|
+
};
|
|
3629
|
+
try {
|
|
3630
|
+
operation = await client.recallMessage(accountId, messageId, serverId, key, signal);
|
|
3631
|
+
const checkOperation = (candidate) => {
|
|
3632
|
+
if (!candidate || typeof candidate.id !== "string" || !candidate.id || operationId && candidate.id !== operationId || candidate.accountId !== accountId || candidate.kind !== "message_recall") throw Error("Recall operation identity is invalid");
|
|
3633
|
+
};
|
|
3634
|
+
checkOperation(operation);
|
|
3635
|
+
operationId = operation.id;
|
|
3636
|
+
const deadline = Date.now() + Math.max(0, Math.min(dependencies.waitMs ?? 2e3, 1e4));
|
|
3637
|
+
while (["queued", "running", "submitting"].includes(operation.state) && Date.now() < deadline) {
|
|
3638
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(250, deadline - Date.now())));
|
|
3639
|
+
guard();
|
|
3640
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
3641
|
+
const next = await client.operation(accountId, operation.id, signal ? AbortSignal.any([signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining));
|
|
3642
|
+
checkOperation(next);
|
|
3643
|
+
operation = next;
|
|
3644
|
+
}
|
|
3645
|
+
guard();
|
|
3646
|
+
} catch (error) {
|
|
3647
|
+
const reason = error instanceof NativeApiError ? error.code : "recall_result_unavailable";
|
|
3648
|
+
return result("needs_review", { reason, note: "Inspect the existing operation or repeat only this same message with the same idempotency key. No automatic second submission was made." });
|
|
3649
|
+
}
|
|
3650
|
+
const receipt = operation.result && typeof operation.result === "object" ? operation.result : {};
|
|
3651
|
+
if (receipt.messageId !== void 0 && receipt.messageId !== messageId || receipt.threadId !== void 0 && receipt.threadId !== conversation || receipt.serverId !== void 0 && receipt.serverId !== serverId) throw Error("Recall operation target identity is invalid");
|
|
3652
|
+
const exactReceipt = receipt.messageId === messageId && receipt.threadId === conversation && receipt.serverId === serverId;
|
|
3653
|
+
const status = operation.state === "succeeded" && exactReceipt && ["recalled", "already_recalled"].includes(String(receipt.status)) ? String(receipt.status) : ["queued", "running", "submitting"].includes(operation.state) ? "pending" : operation.state === "failed" && exactReceipt && ["not_self", "not_eligible", "unavailable", "not_found"].includes(String(receipt.status)) ? String(receipt.status) : "needs_review";
|
|
3654
|
+
const boundedState = (value) => typeof value === "string" && /^[a-z0-9_]{1,80}$/.test(value) ? value : void 0;
|
|
3655
|
+
return result(status, {
|
|
3656
|
+
remoteOutcome: boundedState(receipt.remoteOutcome),
|
|
3657
|
+
profileProjection: boundedState(receipt.profileProjection),
|
|
3658
|
+
note: status === "pending" || status === "needs_review" ? "Inspect this durable operation; do not automatically submit a replacement recall." : void 0
|
|
3659
|
+
});
|
|
3660
|
+
}
|
|
3661
|
+
}];
|
|
3662
|
+
}
|
|
3663
|
+
function registerNativeRecallTools(api) {
|
|
3664
|
+
api.registerTool({ contextVersion: 2, create: (context) => createNativeRecallTools(context) }, { names: names2 });
|
|
3665
|
+
}
|
|
3666
|
+
|
|
2313
3667
|
// index.ts
|
|
2314
3668
|
var plugin = {
|
|
2315
3669
|
id: "agent-wechat",
|
|
@@ -2320,9 +3674,13 @@ var plugin = {
|
|
|
2320
3674
|
setWeChatRuntime(api.runtime);
|
|
2321
3675
|
const selected = resolveWeChatAccount(api.config);
|
|
2322
3676
|
api.registerChannel({ plugin: wechatPlugin });
|
|
3677
|
+
registerNativeHistoryTools(api);
|
|
3678
|
+
registerNativeShareTools(api);
|
|
3679
|
+
registerNativeRecallTools(api);
|
|
2323
3680
|
const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
|
|
2324
3681
|
};
|
|
2325
|
-
const stopPreparedBoundary = registerNativePreparedBoundary(api)
|
|
3682
|
+
const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
|
|
3683
|
+
};
|
|
2326
3684
|
api.registerService({
|
|
2327
3685
|
id: "agent-wechat-native",
|
|
2328
3686
|
reload: { configPrefixes: ["plugins.entries.agent-wechat.config", "channels.agent-wechat", "secrets"] },
|