@agent-wechat/agent-wechat 1.0.0-beta.8 → 1.0.0-beta.9
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 +22 -0
- package/dist/index.js +926 -174
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -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))
|
|
@@ -283,8 +283,24 @@ var NativeWeChatClient = class {
|
|
|
283
283
|
throw new Error("Invalid login idempotency key");
|
|
284
284
|
return this.request(`v1/accounts/${id(accountId)}/auth/login`, { method: "POST", headers: key ? { "Idempotency-Key": key } : void 0, body: JSON.stringify(options) });
|
|
285
285
|
}
|
|
286
|
-
operation(accountId, operationId) {
|
|
287
|
-
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;
|
|
288
304
|
}
|
|
289
305
|
cancelOperation(accountId, operationId) {
|
|
290
306
|
return this.request(`v1/accounts/${id(accountId)}/operations/${id(operationId)}`, { method: "DELETE" });
|
|
@@ -395,14 +411,19 @@ var NativeWeChatClient = class {
|
|
|
395
411
|
}
|
|
396
412
|
return result;
|
|
397
413
|
}
|
|
398
|
-
sendText(accountId, threadId2,
|
|
414
|
+
sendText(accountId, threadId2, text3, idempotencyKey) {
|
|
399
415
|
if (!idempotencyKey || /[\r\n]/.test(idempotencyKey))
|
|
400
416
|
throw new Error("An idempotency key is required");
|
|
401
|
-
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text }) });
|
|
417
|
+
return this.request(`v1/accounts/${id(accountId)}/sends`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ threadId: threadId2, text: text3 }) });
|
|
402
418
|
}
|
|
403
419
|
send(accountId, sendId, signal) {
|
|
404
420
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}`, { signal });
|
|
405
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
|
+
}
|
|
406
427
|
sendByKey(accountId, key) {
|
|
407
428
|
if (!key || key.length > 200)
|
|
408
429
|
throw new Error("Invalid idempotency key");
|
|
@@ -411,11 +432,11 @@ var NativeWeChatClient = class {
|
|
|
411
432
|
cancelSend(accountId, sendId) {
|
|
412
433
|
return this.request(`v1/accounts/${id(accountId)}/sends/${id(sendId)}/cancel`, { method: "POST", body: "{}" });
|
|
413
434
|
}
|
|
414
|
-
async upload(accountId, bytes,
|
|
415
|
-
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))
|
|
416
437
|
throw new Error("Invalid bounded media upload");
|
|
417
438
|
const form = new FormData();
|
|
418
|
-
form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }),
|
|
439
|
+
form.set("file", new Blob([new Uint8Array(bytes).buffer], { type: contentType }), name2);
|
|
419
440
|
return this.request(`v1/accounts/${id(accountId)}/uploads`, { method: "POST", body: form, signal });
|
|
420
441
|
}
|
|
421
442
|
sendMedia(accountId, input, key) {
|
|
@@ -577,11 +598,22 @@ function requireNativeIdentity(value) {
|
|
|
577
598
|
}
|
|
578
599
|
}
|
|
579
600
|
function nativeChildEnvironment(token, environment = process.env) {
|
|
580
|
-
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]]));
|
|
581
602
|
}
|
|
582
603
|
function nativeHostTarget(platform = process.platform, arch = process.arch) {
|
|
583
|
-
|
|
584
|
-
|
|
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;
|
|
585
617
|
}
|
|
586
618
|
async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "", environment = nativeChildEnvironment("")) {
|
|
587
619
|
const target = nativeHostTarget();
|
|
@@ -590,40 +622,48 @@ async function resolveNativeExecutable(explicit, path = process.env.PATH ?? "",
|
|
|
590
622
|
if (!candidate) {
|
|
591
623
|
for (const directory of path.split(delimiter).filter(isAbsolute)) {
|
|
592
624
|
try {
|
|
593
|
-
await access(join(directory,
|
|
594
|
-
candidate = join(directory,
|
|
625
|
+
await access(join(directory, target.executable), constants.X_OK);
|
|
626
|
+
candidate = join(directory, target.executable);
|
|
595
627
|
break;
|
|
596
628
|
} catch {
|
|
597
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
|
+
}
|
|
598
639
|
}
|
|
599
640
|
}
|
|
600
641
|
if (!candidate) throw new Error("native_binary_missing: install native wx separately or configure its absolute path");
|
|
601
642
|
candidate = await realpath(candidate);
|
|
602
|
-
if (candidate.endsWith("/scripts/native-launcher.mjs")) {
|
|
643
|
+
if (candidate.replaceAll("\\", "/").endsWith("/scripts/native-launcher.mjs")) {
|
|
603
644
|
const root = dirname(dirname(candidate));
|
|
604
645
|
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
605
646
|
if (pkg.name !== "@agent-wechat/cli" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)) throw new Error("Unsupported native CLI launcher");
|
|
606
647
|
const manifest = JSON.parse(await readFile(join(root, "native-release.json"), "utf8"));
|
|
607
|
-
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"));
|
|
608
649
|
if (manifest.version !== pkg.version || !asset || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("Native CLI manifest is incompatible");
|
|
609
|
-
candidate = await realpath(join(root, "native", pkg.version,
|
|
650
|
+
candidate = await realpath(join(root, "native", pkg.version, target.key, target.executable));
|
|
610
651
|
const bytes = await readFile(candidate);
|
|
611
652
|
if (bytes.length !== asset.bytes || createHash2("sha256").update(bytes).digest("hex") !== asset.sha256) throw new Error("Installed native executable checksum mismatch");
|
|
612
653
|
}
|
|
613
654
|
const stat = await lstat(candidate);
|
|
614
655
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Native executable must be a regular file");
|
|
615
656
|
await access(candidate, constants.X_OK);
|
|
616
|
-
|
|
617
|
-
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");
|
|
618
658
|
const { stdout } = await exec(candidate, ["--json", "version"], { env: environment, timeout: 5e3, maxBuffer: 65536 });
|
|
619
659
|
const version = JSON.parse(stdout);
|
|
620
660
|
requireNativeIdentity(version);
|
|
621
661
|
if (version.target !== target.rustTarget) throw new Error("native_binary_incompatible: executable architecture differs from this host");
|
|
622
662
|
return candidate;
|
|
623
663
|
}
|
|
624
|
-
async function portOccupied(
|
|
664
|
+
async function portOccupied(url2) {
|
|
625
665
|
return new Promise((resolve, reject) => {
|
|
626
|
-
const socket = createConnection({ host:
|
|
666
|
+
const socket = createConnection({ host: url2.hostname.replace(/^\[|\]$/g, ""), port: Number(url2.port || (url2.protocol === "https:" ? 443 : 80)) });
|
|
627
667
|
socket.setTimeout(1e3);
|
|
628
668
|
socket.once("connect", () => {
|
|
629
669
|
socket.destroy();
|
|
@@ -640,7 +680,7 @@ async function portOccupied(url) {
|
|
|
640
680
|
});
|
|
641
681
|
});
|
|
642
682
|
}
|
|
643
|
-
async function stopOwnedChild(child) {
|
|
683
|
+
async function stopOwnedChild(child, graceful) {
|
|
644
684
|
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
|
|
645
685
|
await new Promise((resolve) => {
|
|
646
686
|
const kill = setTimeout(() => {
|
|
@@ -650,7 +690,10 @@ async function stopOwnedChild(child) {
|
|
|
650
690
|
clearTimeout(kill);
|
|
651
691
|
resolve();
|
|
652
692
|
});
|
|
653
|
-
|
|
693
|
+
if (graceful) void graceful().catch(() => {
|
|
694
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
695
|
+
});
|
|
696
|
+
else child.kill("SIGTERM");
|
|
654
697
|
});
|
|
655
698
|
}
|
|
656
699
|
async function startNativeHost(config) {
|
|
@@ -665,11 +708,11 @@ async function startNativeHost(config) {
|
|
|
665
708
|
});
|
|
666
709
|
}
|
|
667
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");
|
|
668
|
-
const
|
|
669
|
-
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");
|
|
670
713
|
const executable = await resolveNativeExecutable(config.executable, void 0, nativeChildEnvironment(token));
|
|
671
|
-
const client = new NativeWeChatClient({ baseUrl:
|
|
672
|
-
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];
|
|
673
716
|
if (config.serverConfig) args.push("--server-config", config.serverConfig);
|
|
674
717
|
const child = spawn(executable, args, { env: nativeChildEnvironment(token), stdio: ["pipe", "ignore", "pipe"], shell: false });
|
|
675
718
|
child.stderr?.on("data", () => {
|
|
@@ -692,10 +735,21 @@ async function startNativeHost(config) {
|
|
|
692
735
|
if (exited) throw new Error("Native owned child exited before readiness");
|
|
693
736
|
try {
|
|
694
737
|
const status = await client.status();
|
|
695
|
-
const
|
|
738
|
+
const canonical = await realpath(config.dataDir);
|
|
739
|
+
const dataDir = process.platform === "win32" ? toNamespacedPath(canonical) : canonical;
|
|
696
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");
|
|
697
741
|
requireNativeIdentity(status.version);
|
|
698
|
-
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
|
+
}));
|
|
699
753
|
} catch (error) {
|
|
700
754
|
if (String(error).includes("native_owner_conflict")) throw error;
|
|
701
755
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
@@ -1024,25 +1078,25 @@ function appendReference(body, reference) {
|
|
|
1024
1078
|
return body ? `${body}
|
|
1025
1079
|
${reference}` : reference;
|
|
1026
1080
|
}
|
|
1027
|
-
function safeDisplayName(
|
|
1028
|
-
return
|
|
1081
|
+
function safeDisplayName(name2) {
|
|
1082
|
+
return name2.replace(/[\r\n\0]/g, " ").trim() || "attachment";
|
|
1029
1083
|
}
|
|
1030
1084
|
function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
|
|
1031
1085
|
if (!attachment) return originalBody;
|
|
1032
|
-
const
|
|
1086
|
+
const name2 = safeDisplayName(attachment.filename);
|
|
1033
1087
|
if (attachment.status !== "ready" || !attachment.path) {
|
|
1034
1088
|
const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
|
|
1035
1089
|
if (attachment.status === "pending") {
|
|
1036
|
-
return appendReference(originalBody, `[${label} pending: ${
|
|
1090
|
+
return appendReference(originalBody, `[${label} pending: ${name2}]`);
|
|
1037
1091
|
}
|
|
1038
1092
|
if (attachment.status === "expired") {
|
|
1039
|
-
return appendReference(originalBody, `[${label} expired: ${
|
|
1093
|
+
return appendReference(originalBody, `[${label} expired: ${name2}; no cached copy available. Ask the sender to resend.]`);
|
|
1040
1094
|
}
|
|
1041
|
-
return appendReference(originalBody, `[${label} unavailable: ${
|
|
1095
|
+
return appendReference(originalBody, `[${label} unavailable: ${name2} (${attachment.status})]`);
|
|
1042
1096
|
}
|
|
1043
1097
|
if (attachment.kind === "file") {
|
|
1044
|
-
const prefix = originalBody.trim() ===
|
|
1045
|
-
return appendReference(prefix, `[File: ${
|
|
1098
|
+
const prefix = originalBody.trim() === name2 ? "" : originalBody;
|
|
1099
|
+
return appendReference(prefix, `[File: ${name2}]
|
|
1046
1100
|
[Local file: <${attachment.path}>]`);
|
|
1047
1101
|
}
|
|
1048
1102
|
if (exposeNonVisualPath) {
|
|
@@ -1061,11 +1115,117 @@ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = tr
|
|
|
1061
1115
|
return body;
|
|
1062
1116
|
}
|
|
1063
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
|
+
|
|
1064
1188
|
// src/native-media.ts
|
|
1065
1189
|
function kind(type) {
|
|
1066
1190
|
return type === 3 || type === 47 ? "image" : type === 34 ? "audio" : type === 43 ? "video" : type === 49 ? "file" : void 0;
|
|
1067
1191
|
}
|
|
1068
|
-
var terminalJobs = /* @__PURE__ */ new Set(["accepted", "completed", "succeeded", "available", "failed", "cancelled", "expired", "needs_review"]);
|
|
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
|
+
}
|
|
1069
1229
|
async function waitNativeMediaJob(client, accountId, initial, timeoutMs = 65e3, signal, assertCurrent) {
|
|
1070
1230
|
const deadline = Date.now() + timeoutMs;
|
|
1071
1231
|
let job = initial;
|
|
@@ -1103,6 +1263,43 @@ async function prepareImageRepresentation(bytes, mime, cfg) {
|
|
|
1103
1263
|
}
|
|
1104
1264
|
throw Error("Native model image representation exceeds its bound");
|
|
1105
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
|
+
}
|
|
1106
1303
|
async function prepareNativeQuote(params) {
|
|
1107
1304
|
const { client, account, message } = params;
|
|
1108
1305
|
const guard = () => {
|
|
@@ -1112,7 +1309,6 @@ async function prepareNativeQuote(params) {
|
|
|
1112
1309
|
guard();
|
|
1113
1310
|
const comment = (message.body ?? "").slice(0, 16384);
|
|
1114
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 } });
|
|
1115
|
-
if (!message.quote) return unavailable("invalid");
|
|
1116
1312
|
await params.beforeMediaAccess?.();
|
|
1117
1313
|
guard();
|
|
1118
1314
|
let resolution;
|
|
@@ -1121,10 +1317,13 @@ async function prepareNativeQuote(params) {
|
|
|
1121
1317
|
} catch (error) {
|
|
1122
1318
|
guard();
|
|
1123
1319
|
if (!(error instanceof NativeApiError)) throw error;
|
|
1124
|
-
return unavailable("
|
|
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");
|
|
1125
1322
|
}
|
|
1126
1323
|
guard();
|
|
1127
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");
|
|
1128
1327
|
if (resolution.state === "embedded") {
|
|
1129
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");
|
|
1130
1329
|
const author = { senderId: resolution.senderId ?? null, direction: null };
|
|
@@ -1182,6 +1381,25 @@ async function prepareNativeQuote(params) {
|
|
|
1182
1381
|
};
|
|
1183
1382
|
}
|
|
1184
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) {
|
|
1185
1403
|
const { client, account, message, core } = params;
|
|
1186
1404
|
const attachmentKind = kind(message.type);
|
|
1187
1405
|
const guard = () => {
|
|
@@ -1189,6 +1407,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1189
1407
|
params.assertCurrent?.();
|
|
1190
1408
|
};
|
|
1191
1409
|
guard();
|
|
1410
|
+
if (nativeRecallInfo(message)) return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1192
1411
|
const authorize = async () => {
|
|
1193
1412
|
guard();
|
|
1194
1413
|
await params.beforeMediaAccess?.();
|
|
@@ -1196,6 +1415,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1196
1415
|
};
|
|
1197
1416
|
if (message.type === 49 && message.appMessageType === 57) return prepareNativeQuote(params);
|
|
1198
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);
|
|
1199
1419
|
if (message.type === 49 && message.appMessageType != null && message.appMessageType !== 6) return { body: `[Unsupported WeChat app message subtype ${message.appMessageType}]`, pending: false, retryable: false };
|
|
1200
1420
|
if (!attachmentKind) return { body: message.type === 1 ? message.body ?? "" : `[Unsupported WeChat message type ${message.type}]`, pending: false };
|
|
1201
1421
|
const accountId = account.native.accountId;
|
|
@@ -1223,34 +1443,38 @@ async function prepareNativeAttachment(params) {
|
|
|
1223
1443
|
}
|
|
1224
1444
|
let job;
|
|
1225
1445
|
let transferError;
|
|
1226
|
-
if (!asset && initialCandidate?.jobId) {
|
|
1446
|
+
if (!asset && initialCandidate?.jobId && initialCandidate.state !== "recalled") {
|
|
1227
1447
|
await authorize();
|
|
1228
1448
|
job = await client.job(accountId, initialCandidate.jobId, params.signal);
|
|
1229
1449
|
guard();
|
|
1230
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");
|
|
1231
1451
|
assets = await scopedAssets();
|
|
1232
1452
|
asset = select(assets, message.type, quality);
|
|
1453
|
+
if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1233
1454
|
if (!asset && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1234
1455
|
}
|
|
1235
|
-
if (!asset && !job && params.download !== false && !["expired", "unsupported"].includes(transferCandidate()?.state ?? "")) {
|
|
1456
|
+
if (!asset && !job && params.download !== false && !["expired", "unsupported", "recalled"].includes(transferCandidate()?.state ?? "")) {
|
|
1457
|
+
await authorize();
|
|
1236
1458
|
try {
|
|
1237
|
-
await authorize();
|
|
1238
1459
|
job = await client.transferMedia(accountId, { messageId: message.id, assetId: null, quality: transferQuality });
|
|
1239
1460
|
guard();
|
|
1240
1461
|
job = await waitNativeMediaJob(client, accountId, job, params.waitMs ?? 2e4, params.signal, guard);
|
|
1241
1462
|
guard();
|
|
1242
1463
|
} catch (error) {
|
|
1243
1464
|
guard();
|
|
1244
|
-
if (!(error instanceof NativeApiError) || error.
|
|
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 };
|
|
1245
1467
|
transferError = error;
|
|
1246
1468
|
}
|
|
1247
1469
|
assets = await scopedAssets();
|
|
1248
1470
|
asset = select(assets, message.type, quality);
|
|
1471
|
+
if (!asset && transferCandidate()?.state === "recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1249
1472
|
if (!asset && job && ["queued", "running"].includes(job.state)) return { body: `[${attachmentKind} pending]`, pending: true, jobId: job.id, retryable: true };
|
|
1250
1473
|
}
|
|
1251
1474
|
const candidate = transferCandidate();
|
|
1252
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, "_");
|
|
1253
1476
|
if (!asset) {
|
|
1477
|
+
if (candidate?.state === "recalled" || transferError?.code === "message_recalled") return { body: "[Message recalled]", pending: false, retryable: false, reason: "message_recalled" };
|
|
1254
1478
|
const state = candidate?.state === "expired" ? "expired" : candidate?.state === "unsupported" ? "unsupported" : "unavailable";
|
|
1255
1479
|
const attachment2 = { kind: attachmentKind, filename, status: state, expiresAt: candidate?.expiresAt ?? void 0 };
|
|
1256
1480
|
return {
|
|
@@ -1346,7 +1570,7 @@ async function prepareNativeAttachment(params) {
|
|
|
1346
1570
|
pending: false
|
|
1347
1571
|
};
|
|
1348
1572
|
}
|
|
1349
|
-
async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID()) {
|
|
1573
|
+
async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioAsVoice = false, key = randomUUID(), beforeSend) {
|
|
1350
1574
|
const max = Math.min(account.mediaMaxMb * 1024 * 1024, NATIVE_MAX_SOURCE_BYTES);
|
|
1351
1575
|
let bytes;
|
|
1352
1576
|
let filename;
|
|
@@ -1361,7 +1585,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1361
1585
|
}
|
|
1362
1586
|
const reader = response.body?.getReader();
|
|
1363
1587
|
if (!reader) throw new Error("Native outbound source has no bytes");
|
|
1364
|
-
const
|
|
1588
|
+
const chunks2 = [];
|
|
1365
1589
|
let size = 0;
|
|
1366
1590
|
try {
|
|
1367
1591
|
while (true) {
|
|
@@ -1369,7 +1593,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1369
1593
|
if (done) break;
|
|
1370
1594
|
size += value.length;
|
|
1371
1595
|
if (size > max) throw new Error("Native outbound source exceeds configured size");
|
|
1372
|
-
|
|
1596
|
+
chunks2.push(value);
|
|
1373
1597
|
}
|
|
1374
1598
|
} catch (error) {
|
|
1375
1599
|
await reader.cancel();
|
|
@@ -1379,7 +1603,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1379
1603
|
}
|
|
1380
1604
|
bytes = new Uint8Array(size);
|
|
1381
1605
|
let offset = 0;
|
|
1382
|
-
for (const chunk of
|
|
1606
|
+
for (const chunk of chunks2) {
|
|
1383
1607
|
bytes.set(chunk, offset);
|
|
1384
1608
|
offset += chunk.length;
|
|
1385
1609
|
}
|
|
@@ -1391,7 +1615,7 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1391
1615
|
try {
|
|
1392
1616
|
const stat = await file.stat();
|
|
1393
1617
|
if (!stat.isFile() || stat.size === 0 || stat.size > max) throw new Error("Native outbound source must be a bounded regular file");
|
|
1394
|
-
const
|
|
1618
|
+
const chunks2 = [];
|
|
1395
1619
|
let size = 0;
|
|
1396
1620
|
while (true) {
|
|
1397
1621
|
const chunk = Buffer.alloc(Math.min(65536, max + 1 - size));
|
|
@@ -1399,9 +1623,9 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1399
1623
|
if (!bytesRead) break;
|
|
1400
1624
|
size += bytesRead;
|
|
1401
1625
|
if (size > max) throw new Error("Native outbound source exceeds configured size");
|
|
1402
|
-
|
|
1626
|
+
chunks2.push(chunk.subarray(0, bytesRead));
|
|
1403
1627
|
}
|
|
1404
|
-
bytes = Buffer.concat(
|
|
1628
|
+
bytes = Buffer.concat(chunks2, size);
|
|
1405
1629
|
} finally {
|
|
1406
1630
|
await file.close();
|
|
1407
1631
|
}
|
|
@@ -1410,43 +1634,39 @@ async function sendNativeAttachment(client, account, threadId2, mediaUrl, audioA
|
|
|
1410
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";
|
|
1411
1635
|
}
|
|
1412
1636
|
if (audioAsVoice && !mime.startsWith("audio/")) throw new Error("Voice sending requires an explicit audio attachment");
|
|
1637
|
+
await beforeSend?.();
|
|
1413
1638
|
const accountId = account.native.accountId;
|
|
1414
1639
|
const source = await client.upload(accountId, bytes, filename, mime);
|
|
1415
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 };
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const deadline = Date.now() + Math.max(1, Math.min(options.timeoutMs ?? 65e3, 65e3));
|
|
1425
|
-
let delay = Math.max(1, Math.min(options.initialDelayMs ?? 250, 1e3));
|
|
1426
|
-
while (["queued", "submitting"].includes(intent.state) && Date.now() < deadline) {
|
|
1427
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, deadline - Date.now())));
|
|
1428
|
-
const remaining = deadline - Date.now();
|
|
1429
|
-
if (remaining <= 0) break;
|
|
1430
|
-
intent = await client.send(accountId, intent.id, AbortSignal.timeout(remaining));
|
|
1431
|
-
delay = Math.min(delay * 2, 1e3);
|
|
1432
|
-
}
|
|
1433
|
-
if (!["accepted", "sent", "server_accepted"].includes(intent.state)) throw new Error(`Native send ${intent.id} requires inspection (${intent.state}); do not automatically resend`);
|
|
1434
|
-
return intent;
|
|
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 });
|
|
1435
1649
|
}
|
|
1436
1650
|
|
|
1437
1651
|
// src/outbound-media.ts
|
|
1438
|
-
async function sendWeChatMedia(cfg, to,
|
|
1652
|
+
async function sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice = false, idempotencyKey) {
|
|
1439
1653
|
const account = resolveWeChatAccount(cfg);
|
|
1440
1654
|
if (!account?.native) throw new Error("No native daemon configured");
|
|
1441
1655
|
const { client } = await ensureNativeHost(account.native);
|
|
1442
1656
|
const key = idempotencyKey ?? randomUUID2();
|
|
1443
1657
|
if (!mediaUrl) {
|
|
1444
1658
|
if (audioAsVoice) throw new Error("Voice sending requires an audio attachment");
|
|
1445
|
-
const send = await submitNativeText(client, account.native.accountId, to,
|
|
1446
|
-
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
|
+
);
|
|
1447
1667
|
}
|
|
1448
1668
|
const result = await sendNativeAttachment(client, account, to, mediaUrl, audioAsVoice, key);
|
|
1449
|
-
if (
|
|
1669
|
+
if (text3) await submitNativeText(client, account.native.accountId, to, text3, createHash5("sha256").update(`${key}\0caption`).digest("hex"));
|
|
1450
1670
|
return result;
|
|
1451
1671
|
}
|
|
1452
1672
|
|
|
@@ -1474,9 +1694,16 @@ var NativeConsumerState = class {
|
|
|
1474
1694
|
for (const [id2, item] of Object.entries(parsed.items)) {
|
|
1475
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");
|
|
1476
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();
|
|
1477
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");
|
|
1478
1700
|
if (item.state === "dispatching") item.state = "needs_review";
|
|
1479
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
|
+
}
|
|
1480
1707
|
this.value = parsed;
|
|
1481
1708
|
this.persist();
|
|
1482
1709
|
}
|
|
@@ -1484,12 +1711,22 @@ var NativeConsumerState = class {
|
|
|
1484
1711
|
get cursor() {
|
|
1485
1712
|
return this.value.cursor;
|
|
1486
1713
|
}
|
|
1714
|
+
get accountId() {
|
|
1715
|
+
return this.value.accountId;
|
|
1716
|
+
}
|
|
1487
1717
|
items() {
|
|
1488
1718
|
return Object.values(this.value.items);
|
|
1489
1719
|
}
|
|
1490
1720
|
get(messageId) {
|
|
1491
1721
|
return this.value.items[messageId];
|
|
1492
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
|
+
}
|
|
1493
1730
|
persist() {
|
|
1494
1731
|
const directory = dirname2(this.file);
|
|
1495
1732
|
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
@@ -1533,42 +1770,129 @@ var NativeConsumerState = class {
|
|
|
1533
1770
|
accept(cursor, message, origin = "unknown") {
|
|
1534
1771
|
if (!/^\d+$/.test(cursor) || BigInt(cursor) < BigInt(this.value.cursor)) throw new Error("Native journal cursor cannot move backward");
|
|
1535
1772
|
if (message && (!message.id || message.accountId !== this.value.accountId)) throw new Error("Native journal message crosses account boundary");
|
|
1773
|
+
let replaced = false;
|
|
1536
1774
|
this.mutate(() => {
|
|
1537
|
-
if (message &&
|
|
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)) {
|
|
1538
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));
|
|
1539
1784
|
for (const [key] of completed.slice(0, Math.max(0, completed.length - 1e3))) delete this.value.items[key];
|
|
1540
|
-
if (Object.keys(this.value.items).length >= 5e3) throw new Error("Native custody queue is full; operator review required");
|
|
1541
|
-
|
|
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 };
|
|
1542
1801
|
}
|
|
1543
1802
|
this.value.cursor = cursor;
|
|
1544
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;
|
|
1816
|
+
}
|
|
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];
|
|
1545
1865
|
}
|
|
1546
|
-
update(messageId, update) {
|
|
1866
|
+
update(messageId, update, revision) {
|
|
1547
1867
|
const item = this.value.items[messageId];
|
|
1868
|
+
if (revision !== void 0 && item?.revision !== revision) return false;
|
|
1548
1869
|
if (!item) throw new Error("Unknown native custody message");
|
|
1549
1870
|
this.mutate(() => Object.assign(this.value.items[messageId], update));
|
|
1871
|
+
return true;
|
|
1550
1872
|
}
|
|
1551
1873
|
groupHistory(threadId2, beforeId, limit) {
|
|
1552
1874
|
const before = this.value.items[beforeId]?.cursor;
|
|
1553
1875
|
if (!before || limit <= 0) return [];
|
|
1554
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);
|
|
1555
1877
|
}
|
|
1556
|
-
bufferGroupMessage(messageId, limit) {
|
|
1878
|
+
bufferGroupMessage(messageId, limit, revision) {
|
|
1557
1879
|
const item = this.value.items[messageId];
|
|
1880
|
+
if (revision !== void 0 && item?.revision !== revision) return false;
|
|
1558
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");
|
|
1559
1882
|
this.mutate(() => {
|
|
1560
1883
|
Object.assign(this.value.items[messageId], { state: "ignored", groupContext: limit > 0 });
|
|
1561
1884
|
const history = this.items().filter((value) => value.groupContext).sort((a, b) => BigInt(a.cursor) < BigInt(b.cursor) ? -1 : 1);
|
|
1562
|
-
const
|
|
1563
|
-
for (const value of
|
|
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;
|
|
1564
1887
|
const retained = history.filter((value) => value.groupContext);
|
|
1565
1888
|
for (const value of retained.slice(0, Math.max(0, retained.length - 1e3))) value.groupContext = false;
|
|
1566
1889
|
});
|
|
1890
|
+
return true;
|
|
1567
1891
|
}
|
|
1568
1892
|
beginDispatch(messageId, dispatchManifest, historyIds = []) {
|
|
1569
|
-
if (this.value.items[messageId]?.state !== "received" || historyIds.some((id2) => !this.value.items[id2]?.groupContext || this.value.items[id2].message.threadId !== this.value.items[messageId].message.threadId)) throw new Error("Invalid native context dispatch custody");
|
|
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");
|
|
1570
1894
|
this.mutate(() => {
|
|
1571
|
-
Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest });
|
|
1895
|
+
Object.assign(this.value.items[messageId], { state: "dispatching", dispatchManifest, invalidatedByRecall: false });
|
|
1572
1896
|
for (const id2 of historyIds) this.value.items[id2].groupContext = false;
|
|
1573
1897
|
});
|
|
1574
1898
|
}
|
|
@@ -1581,6 +1905,7 @@ var NativeConsumerState = class {
|
|
|
1581
1905
|
if (!item || item.state !== "dispatching") throw new Error("Prepared input manifest is not in dispatch custody");
|
|
1582
1906
|
return item;
|
|
1583
1907
|
});
|
|
1908
|
+
const revisions = items.map((item) => item.revision);
|
|
1584
1909
|
if (new Set(items.map((item) => item.message.threadId)).size !== 1) throw new Error("Prepared input manifest crosses threads");
|
|
1585
1910
|
const manifestHash = createHash6("sha256").update(JSON.stringify({
|
|
1586
1911
|
accountId: this.value.accountId,
|
|
@@ -1591,8 +1916,15 @@ var NativeConsumerState = class {
|
|
|
1591
1916
|
const runId = typeof args[0] === "string" ? args[0] : args[0]?.runId;
|
|
1592
1917
|
if (typeof runId !== "string" || !runId || runId.length > 256) throw new Error("Prepared run callback has no valid runId");
|
|
1593
1918
|
this.mutate(() => {
|
|
1594
|
-
|
|
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()) {
|
|
1595
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");
|
|
1596
1928
|
if (item.runId && item.runId !== runId) throw new Error("Native prepared manifest was assigned to another run; operator review required");
|
|
1597
1929
|
Object.assign(item, { state: "handed_off", runId, manifestHash, readPending: readPolicy === "on_model_dispatch", handoffUnconfirmed: false });
|
|
1598
1930
|
}
|
|
@@ -1611,6 +1943,9 @@ var digest = (value) => createHash7("sha256").update(value).digest("hex");
|
|
|
1611
1943
|
function prune() {
|
|
1612
1944
|
for (const [id2, item] of pending) if (item.expires < Date.now()) pending.delete(id2);
|
|
1613
1945
|
}
|
|
1946
|
+
function nativeSourceMessageSid(message) {
|
|
1947
|
+
return `wechat-native:${message.accountId}:${message.id}:${digest(JSON.stringify([message.threadId, message.serverId ?? null]))}`;
|
|
1948
|
+
}
|
|
1614
1949
|
function nativeDispatchManifest(params) {
|
|
1615
1950
|
const history = params.history ?? [];
|
|
1616
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");
|
|
@@ -1618,7 +1953,7 @@ function nativeDispatchManifest(params) {
|
|
|
1618
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");
|
|
1619
1954
|
return Object.freeze({ ...asset });
|
|
1620
1955
|
}));
|
|
1621
|
-
const messageSid =
|
|
1956
|
+
const messageSid = nativeSourceMessageSid(params.message);
|
|
1622
1957
|
const manifest = {
|
|
1623
1958
|
schemaVersion: 2,
|
|
1624
1959
|
runId: randomUUID4(),
|
|
@@ -1643,14 +1978,15 @@ function nativeDispatchManifest(params) {
|
|
|
1643
1978
|
params.message.mentions,
|
|
1644
1979
|
params.message.serverId,
|
|
1645
1980
|
params.message.appMessageType,
|
|
1646
|
-
params.message.quote
|
|
1981
|
+
params.message.quote,
|
|
1982
|
+
params.message.link
|
|
1647
1983
|
])),
|
|
1648
1984
|
role: "trigger"
|
|
1649
1985
|
})]),
|
|
1650
1986
|
...history.length ? { historyInputs: Object.freeze(history.map((message) => Object.freeze({
|
|
1651
1987
|
id: message.id,
|
|
1652
1988
|
version: message.version ?? "",
|
|
1653
|
-
contentSha256: digest(JSON.stringify([message.id, message.version, message.senderId, message.type, message.body, message.mentions, message.serverId, message.appMessageType, message.quote])),
|
|
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])),
|
|
1654
1990
|
role: "history"
|
|
1655
1991
|
}))) } : {},
|
|
1656
1992
|
assets,
|
|
@@ -1666,30 +2002,40 @@ function trackNativePreparedInput(manifest, record) {
|
|
|
1666
2002
|
if (!enabled || pending.size >= 1024) return;
|
|
1667
2003
|
pending.set(manifest.runId, { manifest, record, expires: Date.now() + 15 * 60 * 1e3 });
|
|
1668
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
|
+
}
|
|
1669
2015
|
function promptMatches(prompt, manifest) {
|
|
1670
2016
|
if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 1024 * 1024) return false;
|
|
1671
|
-
const
|
|
1672
|
-
const candidates = [
|
|
2017
|
+
const text3 = prompt.replace(/^\[[^\]\r\n]{1,96}\] /, "");
|
|
2018
|
+
const candidates = [text3];
|
|
1673
2019
|
const sender = `Conversation info: \u27E6openclaw:ctx\u27E7
|
|
1674
2020
|
\`\`\`json
|
|
1675
2021
|
${JSON.stringify({ sender: { id: manifest.senderId } })}
|
|
1676
2022
|
\`\`\`
|
|
1677
2023
|
|
|
1678
2024
|
`;
|
|
1679
|
-
if (manifest.threadId.endsWith("@chatroom") &&
|
|
1680
|
-
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));
|
|
1681
2027
|
}
|
|
1682
|
-
function normalizedPromptMatches(
|
|
2028
|
+
function normalizedPromptMatches(text3, manifest) {
|
|
1683
2029
|
const runtimeAssets = manifest.assets.filter((asset) => asset.runtimeMedia);
|
|
1684
2030
|
if (runtimeAssets.length) {
|
|
1685
2031
|
if (runtimeAssets.length !== 1) return false;
|
|
1686
2032
|
const asset = runtimeAssets[0];
|
|
1687
|
-
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);
|
|
1688
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;
|
|
1689
|
-
|
|
1690
|
-
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\]$/, "");
|
|
1691
2037
|
}
|
|
1692
|
-
return digest(
|
|
2038
|
+
return digest(text3) === manifest.inputBodySha256;
|
|
1693
2039
|
}
|
|
1694
2040
|
function currentMessageFacts(messages) {
|
|
1695
2041
|
if (!Array.isArray(messages) || messages.length > 1e3) return [];
|
|
@@ -1750,8 +2096,8 @@ function observeNativeAgentEnd(event, ctx) {
|
|
|
1750
2096
|
const blocks = typeof user.content === "string" ? [{ type: "text", text: user.content }] : user.content;
|
|
1751
2097
|
if (!Array.isArray(blocks) || blocks.length > 32) return;
|
|
1752
2098
|
if (blocks.some((block) => !["text", "image"].includes(block?.type) || block.type === "text" && typeof block.text !== "string")) return;
|
|
1753
|
-
const
|
|
1754
|
-
if (!promptMatches(
|
|
2099
|
+
const text3 = blocks.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
|
|
2100
|
+
if (!promptMatches(text3, m)) return;
|
|
1755
2101
|
const images = blocks.filter((block) => block?.type === "image");
|
|
1756
2102
|
const expectedImages = m.assets.filter((asset) => asset.role === "model_image");
|
|
1757
2103
|
if (images.length !== expectedImages.length) return;
|
|
@@ -1761,7 +2107,7 @@ function observeNativeAgentEnd(event, ctx) {
|
|
|
1761
2107
|
const bytes = Buffer.from(image.data, "base64");
|
|
1762
2108
|
if (bytes.length !== asset.byteLength || digest(bytes) !== asset.sha256) return;
|
|
1763
2109
|
}
|
|
1764
|
-
if (m.assets.some((asset) => asset.role === "file_reference" && !
|
|
2110
|
+
if (m.assets.some((asset) => asset.role === "file_reference" && !text3.includes(asset.path))) return;
|
|
1765
2111
|
item.record(m.runId);
|
|
1766
2112
|
pending.delete(m.runId);
|
|
1767
2113
|
}
|
|
@@ -1876,22 +2222,22 @@ async function projectNativeRead(client, accountId, state) {
|
|
|
1876
2222
|
for (const threadId2 of threads) {
|
|
1877
2223
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1878
2224
|
let cursor;
|
|
1879
|
-
let
|
|
2225
|
+
let thread3;
|
|
1880
2226
|
const seen = /* @__PURE__ */ new Set();
|
|
1881
2227
|
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
1882
2228
|
const page = await client.threads(accountId, cursor);
|
|
1883
|
-
|
|
1884
|
-
if (
|
|
2229
|
+
thread3 = page.items.find((value) => !isNativeTombstone(value) && value.id === threadId2);
|
|
2230
|
+
if (thread3 || !page.nextCursor) break;
|
|
1885
2231
|
if (seen.has(page.nextCursor)) throw new Error("Native thread pagination did not advance");
|
|
1886
2232
|
seen.add(page.nextCursor);
|
|
1887
2233
|
cursor = page.nextCursor;
|
|
1888
2234
|
}
|
|
1889
|
-
if (!
|
|
2235
|
+
if (!thread3) break;
|
|
1890
2236
|
try {
|
|
1891
|
-
const receipt = await client.markRead(accountId,
|
|
2237
|
+
const receipt = await client.markRead(accountId, thread3.id, thread3.readStateVersion);
|
|
1892
2238
|
const notification = receipt?.phoneNotification?.state;
|
|
1893
2239
|
const phoneReadState = notification === "accepted" || notification === "rejected" || notification === "needs_review" ? notification : void 0;
|
|
1894
|
-
for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState });
|
|
2240
|
+
for (const item of pending2.filter((item2) => item2.message.threadId === threadId2)) state.update(item.message.id, { readPending: false, phoneReadState }, item.revision);
|
|
1895
2241
|
break;
|
|
1896
2242
|
} catch (error) {
|
|
1897
2243
|
if (error instanceof NativeApiError && error.status === 409 && attempt === 0) continue;
|
|
@@ -1900,13 +2246,72 @@ async function projectNativeRead(client, accountId, state) {
|
|
|
1900
2246
|
}
|
|
1901
2247
|
}
|
|
1902
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
|
+
}
|
|
1903
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) {
|
|
1904
2308
|
const { client, state, account, message, cfg, core } = params;
|
|
1905
2309
|
const readPolicy = account.native?.readPolicy ?? "on_inbound";
|
|
1906
2310
|
const custody = state.get(message.id);
|
|
1907
|
-
if (
|
|
2311
|
+
if (state.isRecalled(message)) return "ignored";
|
|
2312
|
+
if (!custody || custody.state !== "received" || !state.isCurrent(message, revision)) return "needs_review";
|
|
1908
2313
|
if (custody.origin !== "live" || message.direction !== "incoming" || ![1, 3, 34, 43, 47, 49].includes(message.type) || message.type === 1 && !message.body || !message.senderId) {
|
|
1909
|
-
state.update(message.id, { state: "ignored" });
|
|
2314
|
+
state.update(message.id, { state: "ignored" }, revision);
|
|
1910
2315
|
return "ignored";
|
|
1911
2316
|
}
|
|
1912
2317
|
const isGroup = message.threadId.endsWith("@chatroom");
|
|
@@ -1941,32 +2346,50 @@ async function dispatchNativeMessage(params) {
|
|
|
1941
2346
|
const unauthorizedDirectCommand = !isGroup && ingress.ingress.reasonCode === "control_command_unauthorized";
|
|
1942
2347
|
if (ingress.ingress.admission !== "dispatch" && !unauthorizedDirectCommand) {
|
|
1943
2348
|
if (isGroup && policy.requireMention && !wasMentioned && !hasControlCommand && (await senderAdmission(message.senderId)).ingress.admission === "dispatch") {
|
|
1944
|
-
state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg));
|
|
2349
|
+
state.bufferGroupMessage(message.id, nativeGroupHistoryLimit(cfg), revision);
|
|
1945
2350
|
return "ignored";
|
|
1946
2351
|
}
|
|
1947
|
-
state.update(message.id, { state: "ignored" });
|
|
2352
|
+
state.update(message.id, { state: "ignored" }, revision);
|
|
1948
2353
|
return "ignored";
|
|
1949
2354
|
}
|
|
1950
2355
|
let preparedMedia;
|
|
1951
2356
|
const allowQuotedMessage = async (original) => original.direction === "outgoing" || !!original.senderId && (await senderAdmission(original.senderId)).ingress.admission === "dispatch";
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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 });
|
|
1958
2368
|
if (preparedMedia.pending) return "pending";
|
|
1959
2369
|
preparedMedia = await transcribeNativeAttachment(preparedMedia, core, cfg);
|
|
2370
|
+
await recheck([message]);
|
|
1960
2371
|
const buffered = isGroup && !hasControlCommand ? state.groupHistory(message.threadId, message.id, nativeGroupHistoryLimit(cfg)) : [];
|
|
1961
2372
|
const admitted = [];
|
|
1962
2373
|
for (const item of buffered) {
|
|
1963
2374
|
if (item.message.senderId && (await senderAdmission(item.message.senderId)).ingress.admission === "dispatch") admitted.push(item.message);
|
|
1964
2375
|
}
|
|
1965
2376
|
const history = await prepareNativeHistory(admitted, async (historical) => transcribeNativeAttachment(
|
|
1966
|
-
await (params.prepareAttachment ?? prepareNativeAttachment)({
|
|
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
|
+
}),
|
|
1967
2389
|
core,
|
|
1968
2390
|
cfg
|
|
1969
2391
|
));
|
|
2392
|
+
await recheck([message, ...admitted]);
|
|
1970
2393
|
const context = buildNativeContext(history, message, preparedMedia);
|
|
1971
2394
|
const inputBody = context.inputBody;
|
|
1972
2395
|
const route = core.channel.routing.resolveAgentRoute({
|
|
@@ -1996,7 +2419,7 @@ async function dispatchNativeMessage(params) {
|
|
|
1996
2419
|
SenderId: message.senderId,
|
|
1997
2420
|
Provider: "agent-wechat",
|
|
1998
2421
|
Surface: "agent-wechat",
|
|
1999
|
-
MessageSid:
|
|
2422
|
+
MessageSid: nativeSourceMessageSid(message),
|
|
2000
2423
|
WasMentioned: isGroup ? wasMentioned : void 0,
|
|
2001
2424
|
Attachments: context.attachments,
|
|
2002
2425
|
InboundHistory: context.history,
|
|
@@ -2006,6 +2429,7 @@ async function dispatchNativeMessage(params) {
|
|
|
2006
2429
|
...context.mediaPath ? { MediaPath: context.mediaPath, MediaUrl: context.mediaPath, MediaType: context.mediaMime } : {}
|
|
2007
2430
|
});
|
|
2008
2431
|
await core.channel.session.recordInboundSession({ storePath, sessionKey: route.sessionKey, ctx });
|
|
2432
|
+
await recheck([message, ...admitted]);
|
|
2009
2433
|
const pipeline = (params.createPipeline ?? createChannelMessageReplyPipeline)({ cfg, agentId: route.agentId, channel: "agent-wechat", accountId: account.accountId });
|
|
2010
2434
|
const { onModelSelected, ...prefixOptions } = pipeline;
|
|
2011
2435
|
const dispatchManifest = nativeDispatchManifest({
|
|
@@ -2018,9 +2442,11 @@ async function dispatchNativeMessage(params) {
|
|
|
2018
2442
|
assets: context.assets,
|
|
2019
2443
|
history: admitted
|
|
2020
2444
|
});
|
|
2445
|
+
assertCurrent();
|
|
2021
2446
|
state.beginDispatch(message.id, dispatchManifest, buffered.map((item) => item.message.id));
|
|
2022
|
-
const callback = readPolicy !== "on_inbound" && (message.type === 1 || message.type === 49 && message.appMessageType === 19 && !!message.body || preparedMedia.quote?.type === 1 && ["resolved", "embedded"].includes(preparedMedia.quote.state) || preparedMedia.attachment?.status === "ready" && preparedMedia.suppliedAssets?.length) ? state.preparedCallback([message.id], readPolicy) : void 0;
|
|
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;
|
|
2023
2448
|
if (callback) trackNativePreparedInput(dispatchManifest, callback);
|
|
2449
|
+
const beforeSend = () => recheck([message, ...admitted]);
|
|
2024
2450
|
let failed = false;
|
|
2025
2451
|
let delivery = 0;
|
|
2026
2452
|
try {
|
|
@@ -2030,15 +2456,19 @@ async function dispatchNativeMessage(params) {
|
|
|
2030
2456
|
dispatcherOptions: {
|
|
2031
2457
|
...prefixOptions,
|
|
2032
2458
|
deliver: async (payload) => {
|
|
2459
|
+
params.signal?.throwIfAborted();
|
|
2460
|
+
if (state.get(message.id)?.invalidatedByRecall) return;
|
|
2461
|
+
await beforeSend();
|
|
2033
2462
|
const urls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
2034
2463
|
if (urls.length > 16) throw new Error("Native reply media count exceeds its bound");
|
|
2035
2464
|
if (payload.audioAsVoice && !urls.length) throw new Error("Native voice reply requires an audio attachment");
|
|
2036
|
-
for (const
|
|
2037
|
-
const key = createHash8("sha256").update(`${
|
|
2038
|
-
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);
|
|
2039
2468
|
}
|
|
2040
2469
|
if (payload.text) {
|
|
2041
|
-
|
|
2470
|
+
await beforeSend();
|
|
2471
|
+
const key = createHash8("sha256").update(`${dispatchManifest.messageSid}\0${delivery++}`).digest("hex");
|
|
2042
2472
|
await submitNativeText(client, account.native.accountId, message.threadId, payload.text, key);
|
|
2043
2473
|
}
|
|
2044
2474
|
if (urls.length || payload.text) params.onDelivered?.();
|
|
@@ -2053,18 +2483,34 @@ async function dispatchNativeMessage(params) {
|
|
|
2053
2483
|
// and media-snapshot bridge; unsupported or ambiguous handoffs leave reads unchanged.
|
|
2054
2484
|
replyOptions: { runId: dispatchManifest.runId, onModelSelected, onPreparedAgentRunStart: callback }
|
|
2055
2485
|
});
|
|
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";
|
|
2056
2489
|
const readPending = readPolicy === "on_inbound" && !failed && ingress.ingress.admission === "dispatch" ? true : state.get(message.id)?.readPending;
|
|
2057
2490
|
state.update(message.id, {
|
|
2058
2491
|
state: failed ? "needs_review" : "completed",
|
|
2059
2492
|
readPending,
|
|
2060
2493
|
handoffUnconfirmed: readPolicy === "on_model_dispatch" && !state.get(message.id)?.runId
|
|
2061
|
-
});
|
|
2494
|
+
}, revision);
|
|
2062
2495
|
return failed ? "needs_review" : "completed";
|
|
2063
2496
|
} catch {
|
|
2064
|
-
state.
|
|
2497
|
+
if (state.isRecalled(message)) return "ignored";
|
|
2498
|
+
state.update(message.id, { state: "needs_review" }, revision);
|
|
2065
2499
|
return "needs_review";
|
|
2066
2500
|
}
|
|
2067
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
|
+
}
|
|
2068
2514
|
function startNativeReadPump(task, failed, intervalMs = 5e3) {
|
|
2069
2515
|
let active2 = false, stopped = false;
|
|
2070
2516
|
const timer = setInterval(() => {
|
|
@@ -2110,6 +2556,7 @@ async function startNativeMonitor(opts) {
|
|
|
2110
2556
|
message: item.message,
|
|
2111
2557
|
cfg,
|
|
2112
2558
|
core,
|
|
2559
|
+
signal: opts.abortSignal,
|
|
2113
2560
|
onDelivered: () => opts.setStatus({ accountId: account.accountId, lastOutboundAt: Date.now() })
|
|
2114
2561
|
});
|
|
2115
2562
|
}
|
|
@@ -2130,9 +2577,24 @@ async function startNativeMonitor(opts) {
|
|
|
2130
2577
|
handoffUnconfirmed
|
|
2131
2578
|
});
|
|
2132
2579
|
};
|
|
2133
|
-
|
|
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
|
+
};
|
|
2134
2595
|
const stopReadPump = startNativeReadPump(async () => {
|
|
2135
2596
|
await flushRead();
|
|
2597
|
+
scheduleProcess();
|
|
2136
2598
|
if (!opts.abortSignal.aborted) opts.setStatus({
|
|
2137
2599
|
accountId: opts.account.accountId,
|
|
2138
2600
|
handoffUnconfirmed: native.readPolicy === "on_model_dispatch" && state.items().some((value) => value.handoffUnconfirmed)
|
|
@@ -2142,18 +2604,15 @@ async function startNativeMonitor(opts) {
|
|
|
2142
2604
|
while (!opts.abortSignal.aborted) {
|
|
2143
2605
|
try {
|
|
2144
2606
|
if (state.cursor !== "0") await host2.client.ack(native.accountId, "openclaw", state.cursor);
|
|
2145
|
-
|
|
2607
|
+
intakeReady = false;
|
|
2608
|
+
await synchronizeNativeEvents(host2.client, native.accountId, state);
|
|
2609
|
+
intakeReady = true;
|
|
2610
|
+
scheduleProcess();
|
|
2146
2611
|
for await (const event of host2.client.watch(native.accountId, state.cursor, opts.abortSignal)) {
|
|
2147
|
-
|
|
2148
|
-
if (event.
|
|
2149
|
-
if (typeof event.data.messageId !== "string") throw new Error("Native committed message identity is missing; cursor retained");
|
|
2150
|
-
message = await host2.client.message(native.accountId, event.data.messageId);
|
|
2151
|
-
if (message.threadId !== event.data.threadId) throw new Error("Native committed message thread identity mismatch");
|
|
2152
|
-
}
|
|
2153
|
-
state.accept(event.cursor, message, typeof event.data.origin === "string" ? event.data.origin : "unknown");
|
|
2154
|
-
if (message?.direction === "incoming" && event.data.origin === "live") opts.setStatus({ accountId: opts.account.accountId, lastInboundAt: Date.now() });
|
|
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() });
|
|
2155
2614
|
await host2.client.ack(native.accountId, "openclaw", state.cursor);
|
|
2156
|
-
|
|
2615
|
+
scheduleProcess();
|
|
2157
2616
|
backoff = 250;
|
|
2158
2617
|
}
|
|
2159
2618
|
if (!opts.abortSignal.aborted) throw new Error("Native event stream closed");
|
|
@@ -2223,11 +2682,17 @@ async function nativeLoginWait(accountId, timeoutMs = 3e4, stopAtQr = false) {
|
|
|
2223
2682
|
}
|
|
2224
2683
|
return { connected: false, message: "Native login remains pending; wait on the same operation rather than starting a replacement" };
|
|
2225
2684
|
}
|
|
2226
|
-
async function sendNativeText(account, threadId2,
|
|
2685
|
+
async function sendNativeText(account, threadId2, text3, key = randomUUID5()) {
|
|
2227
2686
|
const native = account.native;
|
|
2228
2687
|
const { client } = await ensureNativeHost(native);
|
|
2229
|
-
const send = await submitNativeText(client, native.accountId, threadId2,
|
|
2230
|
-
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
|
+
);
|
|
2231
2696
|
}
|
|
2232
2697
|
function createNativeLoginTool(account) {
|
|
2233
2698
|
return {
|
|
@@ -2254,10 +2719,10 @@ function createNativeLoginTool(account) {
|
|
|
2254
2719
|
}
|
|
2255
2720
|
|
|
2256
2721
|
// src/channel.ts
|
|
2257
|
-
async function sendWeChatText(cfg, to,
|
|
2722
|
+
async function sendWeChatText(cfg, to, text3) {
|
|
2258
2723
|
const account = resolveWeChatAccount(cfg);
|
|
2259
2724
|
if (!account?.serverUrl) throw new Error("No native daemon configured");
|
|
2260
|
-
return sendNativeText(account, to,
|
|
2725
|
+
return sendNativeText(account, to, text3);
|
|
2261
2726
|
}
|
|
2262
2727
|
var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
|
|
2263
2728
|
id: "agent-wechat",
|
|
@@ -2267,13 +2732,13 @@ var wechatMessageAdapter = createChannelMessageAdapterFromOutbound({
|
|
|
2267
2732
|
messageSendingHooks: true
|
|
2268
2733
|
},
|
|
2269
2734
|
outbound: {
|
|
2270
|
-
sendText: async ({ cfg, to, text }) => ({
|
|
2735
|
+
sendText: async ({ cfg, to, text: text3 }) => ({
|
|
2271
2736
|
channel: "agent-wechat",
|
|
2272
|
-
|
|
2737
|
+
...await sendWeChatText(cfg, to, text3)
|
|
2273
2738
|
}),
|
|
2274
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
|
|
2739
|
+
sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
|
|
2275
2740
|
channel: "agent-wechat",
|
|
2276
|
-
|
|
2741
|
+
...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
|
|
2277
2742
|
})
|
|
2278
2743
|
}
|
|
2279
2744
|
});
|
|
@@ -2396,7 +2861,7 @@ var wechatPlugin = {
|
|
|
2396
2861
|
},
|
|
2397
2862
|
// ---- Mention adapter ----
|
|
2398
2863
|
mentions: {
|
|
2399
|
-
stripMentions: ({ text, ctx }) => normalizeWeChatCommandBody(
|
|
2864
|
+
stripMentions: ({ text: text3, ctx }) => normalizeWeChatCommandBody(text3, {
|
|
2400
2865
|
isGroup: ctx.ChatType === "group",
|
|
2401
2866
|
wasMentioned: ctx.WasMentioned === true
|
|
2402
2867
|
})
|
|
@@ -2415,13 +2880,13 @@ var wechatPlugin = {
|
|
|
2415
2880
|
// ---- Outbound adapter for OpenClaw hosts using the outbound hook ----
|
|
2416
2881
|
outbound: {
|
|
2417
2882
|
deliveryMode: "direct",
|
|
2418
|
-
sendText: async ({ cfg, to, text }) => ({
|
|
2883
|
+
sendText: async ({ cfg, to, text: text3 }) => ({
|
|
2419
2884
|
channel: "agent-wechat",
|
|
2420
|
-
|
|
2885
|
+
...await sendWeChatText(cfg, to, text3)
|
|
2421
2886
|
}),
|
|
2422
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice }) => ({
|
|
2887
|
+
sendMedia: async ({ cfg, to, text: text3, mediaUrl, audioAsVoice }) => ({
|
|
2423
2888
|
channel: "agent-wechat",
|
|
2424
|
-
|
|
2889
|
+
...await sendWeChatMedia(cfg, to, text3, mediaUrl, audioAsVoice)
|
|
2425
2890
|
})
|
|
2426
2891
|
},
|
|
2427
2892
|
message: wechatMessageAdapter,
|
|
@@ -2602,9 +3067,9 @@ function createNativeFacade(config, dependencies = {}) {
|
|
|
2602
3067
|
try {
|
|
2603
3068
|
const host2 = dependencies.endpoint ? void 0 : await ensureNativeHost(config);
|
|
2604
3069
|
const headers = new Headers();
|
|
2605
|
-
for (const
|
|
2606
|
-
const value = req.headers[
|
|
2607
|
-
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);
|
|
2608
3073
|
}
|
|
2609
3074
|
const length = req.headers["content-length"];
|
|
2610
3075
|
if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY)) {
|
|
@@ -2628,9 +3093,9 @@ function createNativeFacade(config, dependencies = {}) {
|
|
|
2628
3093
|
response = await (dependencies.fetch ?? fetch)(new URL(relative, dependencies.endpoint), init);
|
|
2629
3094
|
}
|
|
2630
3095
|
res.statusCode = response.status;
|
|
2631
|
-
for (const
|
|
2632
|
-
const value = response.headers.get(
|
|
2633
|
-
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);
|
|
2634
3099
|
}
|
|
2635
3100
|
res.setHeader("x-content-type-options", "nosniff");
|
|
2636
3101
|
const reader = response.body?.getReader();
|
|
@@ -2689,18 +3154,29 @@ function object(value) {
|
|
|
2689
3154
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected tool arguments");
|
|
2690
3155
|
return value;
|
|
2691
3156
|
}
|
|
2692
|
-
function string(value,
|
|
2693
|
-
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw Error(`Invalid ${
|
|
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}`);
|
|
2694
3159
|
return value.trim();
|
|
2695
3160
|
}
|
|
2696
3161
|
function threadId(value) {
|
|
2697
3162
|
return string(string(value, "thread ID").replace(/^(?:agent-)?wechat:(?:group:)?/i, ""), "thread ID");
|
|
2698
3163
|
}
|
|
2699
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
|
+
};
|
|
2700
3175
|
const isQuote = message.type === 49 && message.appMessageType === 57;
|
|
2701
3176
|
const isForward = message.type === 49 && message.appMessageType === 19;
|
|
2702
|
-
const
|
|
2703
|
-
const
|
|
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 ?? ""}
|
|
2704
3180
|
[Quoted original not retrieved; use ${retrievalTool} with this message ID]` : `[WeChat ${kind2} message; attachment content has not been retrieved by this tool]`;
|
|
2705
3181
|
return {
|
|
2706
3182
|
id: message.id,
|
|
@@ -2711,6 +3187,7 @@ function messageResult(message, retrievalTool = "wechat_media") {
|
|
|
2711
3187
|
type: message.type,
|
|
2712
3188
|
appMessageType: message.appMessageType,
|
|
2713
3189
|
quote: message.quote,
|
|
3190
|
+
link: isLink ? message.link : void 0,
|
|
2714
3191
|
createdAt: message.createdAt,
|
|
2715
3192
|
body: body.slice(0, 4e3),
|
|
2716
3193
|
bodyTruncated: body.length > 4e3,
|
|
@@ -2728,6 +3205,7 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2728
3205
|
if (!currentConfig()) return [];
|
|
2729
3206
|
const selected = resolveWeChatAccount(currentConfig());
|
|
2730
3207
|
if (!selected?.native || !selected.enabled) return [];
|
|
3208
|
+
const configurationKey = nativeConfigurationKey(selected.native);
|
|
2731
3209
|
if (!archive && context.messageChannel !== "agent-wechat") return [];
|
|
2732
3210
|
if (!archive && context.agentAccountId && context.agentAccountId !== selected.accountId) return [];
|
|
2733
3211
|
if (!archive && context.deliveryContext?.channel === "agent-wechat" && context.deliveryContext.accountId && context.deliveryContext.accountId !== selected.accountId) return [];
|
|
@@ -2749,9 +3227,9 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2749
3227
|
if (!currentThread) return [];
|
|
2750
3228
|
}
|
|
2751
3229
|
const selectedId = selected.native.accountId;
|
|
2752
|
-
const tool = (
|
|
2753
|
-
name,
|
|
2754
|
-
label:
|
|
3230
|
+
const tool = (name2, action, description, properties, required = []) => ({
|
|
3231
|
+
name: name2,
|
|
3232
|
+
label: name2.replaceAll("_", " "),
|
|
2755
3233
|
description,
|
|
2756
3234
|
// The pinned host's tool_call wrapper JSON-serializes image blocks. Its
|
|
2757
3235
|
// public direct-only surface preserves actual image content for vision.
|
|
@@ -2764,7 +3242,7 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2764
3242
|
context.assertInvocationCurrent?.();
|
|
2765
3243
|
const cfg2 = currentConfig();
|
|
2766
3244
|
const account2 = resolveWeChatAccount(cfg2);
|
|
2767
|
-
if (!account2?.enabled || !account2.native || account2.native.accountId !== selectedId || account2.accountId !== selected.accountId) throw Error("WeChat account configuration changed; start a new turn");
|
|
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");
|
|
2768
3246
|
return { cfg: cfg2, account: account2, native: account2.native };
|
|
2769
3247
|
};
|
|
2770
3248
|
const { cfg, account, native } = guard();
|
|
@@ -2806,10 +3284,10 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2806
3284
|
if (action === "threads") {
|
|
2807
3285
|
const page = await client.threads(accountId, { limit, cursor, ...query ? { query } : {} });
|
|
2808
3286
|
const items = [];
|
|
2809
|
-
for (const
|
|
2810
|
-
if (isNativeTombstone(
|
|
2811
|
-
if (
|
|
2812
|
-
items.push({ id:
|
|
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 });
|
|
2813
3291
|
}
|
|
2814
3292
|
result = { ...page, items, scope: "local_profile", note: "Locally stored threads; coverage may be partial. This lookup does not mark threads read." };
|
|
2815
3293
|
} else if (action === "message" || action === "media") {
|
|
@@ -2819,6 +3297,18 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2819
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");
|
|
2820
3298
|
result = messageResult(message, archive ? "wechat_archive_media" : "wechat_media");
|
|
2821
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
|
+
};
|
|
2822
3312
|
let prepared;
|
|
2823
3313
|
try {
|
|
2824
3314
|
prepared = await prepareNativeAttachment({
|
|
@@ -2835,6 +3325,7 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2835
3325
|
allowQuotedMessage: async (original) => original.direction === "outgoing" || await allowed(original.senderId ?? void 0),
|
|
2836
3326
|
beforeMediaAccess: async () => {
|
|
2837
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();
|
|
2838
3329
|
}
|
|
2839
3330
|
});
|
|
2840
3331
|
} catch (error) {
|
|
@@ -2843,7 +3334,7 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2843
3334
|
const details2 = {
|
|
2844
3335
|
messageId: message.id,
|
|
2845
3336
|
threadId: message.threadId,
|
|
2846
|
-
status: error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
|
|
3337
|
+
status: error.code === "message_recalled" ? "recalled" : error.retryable ? "pending" : ["unsupported_profile", "unsupported_media", "unsupported_media_variant"].includes(error.code) ? "unsupported" : "unavailable",
|
|
2847
3338
|
retryable: error.retryable,
|
|
2848
3339
|
reason: error.code
|
|
2849
3340
|
};
|
|
@@ -2854,9 +3345,10 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2854
3345
|
const details = {
|
|
2855
3346
|
messageId: message.id,
|
|
2856
3347
|
threadId: message.threadId,
|
|
2857
|
-
status: prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
|
|
3348
|
+
status: prepared.reason === "message_recalled" ? "recalled" : prepared.pending ? "pending" : prepared.attachment?.status ?? prepared.quote?.state ?? "unsupported",
|
|
2858
3349
|
attachment: prepared.attachment,
|
|
2859
3350
|
quote: prepared.quote,
|
|
3351
|
+
link: ["message_recalled", "media_identity_unavailable", "media_source_changed"].includes(prepared.reason ?? "") ? void 0 : message.link,
|
|
2860
3352
|
variant: prepared.variant,
|
|
2861
3353
|
jobId: prepared.jobId,
|
|
2862
3354
|
retryable: prepared.retryable ?? false,
|
|
@@ -2866,6 +3358,13 @@ function createLookupTools(context, dependencies, archive) {
|
|
|
2866
3358
|
const image = prepared.suppliedAssets?.find((asset) => asset.role === "model_image");
|
|
2867
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 };
|
|
2868
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
|
+
}
|
|
2869
3368
|
return output;
|
|
2870
3369
|
}
|
|
2871
3370
|
} else {
|
|
@@ -2914,6 +3413,257 @@ function registerNativeHistoryTools(api) {
|
|
|
2914
3413
|
api.registerTool({ contextVersion: 2, create: (context) => createNativeArchiveTools(context) }, { names: archiveNames, optional: true });
|
|
2915
3414
|
}
|
|
2916
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
|
+
|
|
2917
3667
|
// index.ts
|
|
2918
3668
|
var plugin = {
|
|
2919
3669
|
id: "agent-wechat",
|
|
@@ -2925,6 +3675,8 @@ var plugin = {
|
|
|
2925
3675
|
const selected = resolveWeChatAccount(api.config);
|
|
2926
3676
|
api.registerChannel({ plugin: wechatPlugin });
|
|
2927
3677
|
registerNativeHistoryTools(api);
|
|
3678
|
+
registerNativeShareTools(api);
|
|
3679
|
+
registerNativeRecallTools(api);
|
|
2928
3680
|
const stopFacade = selected?.native ? registerNativeFacade(api, selected.native) : () => {
|
|
2929
3681
|
};
|
|
2930
3682
|
const stopPreparedBoundary = selected?.native?.readPolicy && selected.native.readPolicy !== "on_inbound" ? registerNativePreparedBoundary(api) : () => {
|