@hraness/ghostget 0.18.0 → 0.18.3
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/CHANGELOG.md +315 -0
- package/README.md +7 -7
- package/dist/apple-photos-client.js +2 -1
- package/dist/beeper-client.js +2 -1
- package/dist/client.js +1 -0
- package/dist/imessage-direct-install-2gm3kge7.js +324 -0
- package/dist/index-01eeae9e.js +5 -0
- package/dist/index-2ymnp8xv.js +145 -0
- package/dist/{index-9wca02er.js → index-cnbz0gws.js} +1 -1
- package/dist/index-en5hycxp.js +175 -0
- package/dist/index-n4szk3nw.js +50 -0
- package/dist/index-yq6maz71.js +1105 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index.js +4 -47
- package/dist/messaging-automation-api.js +21 -0
- package/dist/messaging-automation-j4274hvc.js +609 -0
- package/dist/messaging-native-install-8w1j36ah.js +16 -0
- package/dist/messaging.js +1 -0
- package/dist/omni-client.js +1 -0
- package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
- package/dist/whatsapp-client.js +1 -0
- package/docs/control-panel.md +2 -2
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +22 -2
- package/skills/ghostget/references/control-panel.md +1 -1
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +215 -13
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/args.ts +18 -5
- package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
- package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
- package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
- package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
- package/src/beeper-client-types.ts +1 -1
- package/src/ghostget.ts +16 -4
- package/src/messaging-automation-api.ts +12 -0
- package/src/messaging-automation-descriptors.ts +29 -0
- package/src/messaging-automation-factory.ts +148 -0
- package/src/messaging-automation-server.ts +183 -0
- package/src/messaging-automation-types.ts +155 -0
- package/src/messaging-automation-validation.ts +110 -0
- package/src/messaging-automation.ts +356 -0
- package/src/plugins/imessage-direct/plugin.ts +12 -1
- package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
- package/src/plugins/linkedin-web/plugin.ts +1 -1
- package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
- package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
- package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
- package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
- package/src/provider-plugin.ts +14 -0
- package/src/providers/imessage-automation.ts +250 -0
- package/src/providers/imessage-direct-install.ts +7 -0
- package/src/providers/imessage-direct-runtime.ts +32 -2
- package/src/providers/imessage-direct.ts +13 -3
- package/src/providers/linkedin-contact-failure.ts +21 -0
- package/src/providers/linkedin-contact-platform.ts +8 -1
- package/src/providers/linkedin-contact-program.ts +31 -6
- package/src/providers/linkedin-web-contact.ts +1072 -51
- package/src/providers/linkedin-web-profile-browser.ts +659 -45
- package/src/providers/linkedin-web-runtime.ts +1 -0
- package/src/providers/linkedin-web.ts +46 -2
- package/src/providers/messaging-native-artifacts.ts +38 -0
- package/src/providers/messaging-native-install.ts +75 -0
- package/src/providers/whatsapp-automation-runtime.ts +240 -0
- package/src/providers/whatsapp-automation.ts +153 -0
- package/src/usage.ts +3 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- package/tsconfig.json +3 -0
|
@@ -802,6 +802,7 @@ function linkedInProfileStatsFailure(
|
|
|
802
802
|
"execution-context": "contained-browser execution context",
|
|
803
803
|
"identity-json": "signed-in identity JSON",
|
|
804
804
|
"output-bound": "contained-browser output bound",
|
|
805
|
+
"page-binding": "reviewed profile page-instance binding",
|
|
805
806
|
"provider-fetch": "contained-browser first-party fetch",
|
|
806
807
|
"response-envelope": "bounded response envelope",
|
|
807
808
|
"response-rejected": "first-party page response",
|
|
@@ -57,7 +57,7 @@ export type LinkedInWebEvidence =
|
|
|
57
57
|
|
|
58
58
|
type LinkedInWebReadRequestRule = {
|
|
59
59
|
readonly kind: "registered-query" | "restli-read" | "server-rendered-read";
|
|
60
|
-
readonly method: "GET";
|
|
60
|
+
readonly method: "GET" | "POST";
|
|
61
61
|
readonly path: string;
|
|
62
62
|
readonly queryPrefix: string | null;
|
|
63
63
|
readonly allowedQueryParameters: readonly string[];
|
|
@@ -110,6 +110,25 @@ export const LINKEDIN_WEB_OPERATIONS = {
|
|
|
110
110
|
allowedQueryParameters: ["includeWebMetadata", "queryId", "queryName", "variables"],
|
|
111
111
|
requiredQueryParameters: ["includeWebMetadata", "variables"],
|
|
112
112
|
fixedQueryParameters: [["includeWebMetadata", "true"]],
|
|
113
|
+
}, {
|
|
114
|
+
kind: "server-rendered-read",
|
|
115
|
+
method: "GET",
|
|
116
|
+
path: "/in/:publicIdentifier/overlay/contact-info/",
|
|
117
|
+
queryPrefix: null,
|
|
118
|
+
allowedQueryParameters: [],
|
|
119
|
+
requiredQueryParameters: [],
|
|
120
|
+
fixedQueryParameters: [],
|
|
121
|
+
}, {
|
|
122
|
+
kind: "server-rendered-read",
|
|
123
|
+
method: "POST",
|
|
124
|
+
path: "/flagship-web/rsc-action/actions/navigation?screenId=com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay&sduiid=:",
|
|
125
|
+
queryPrefix: null,
|
|
126
|
+
allowedQueryParameters: ["screenId", "sduiid"],
|
|
127
|
+
requiredQueryParameters: ["screenId", "sduiid"],
|
|
128
|
+
fixedQueryParameters: [[
|
|
129
|
+
"screenId",
|
|
130
|
+
"com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay",
|
|
131
|
+
]],
|
|
113
132
|
}],
|
|
114
133
|
},
|
|
115
134
|
"feeds.read": {
|
|
@@ -3200,7 +3219,9 @@ export function assertLinkedInWebR1RequestAllowed(
|
|
|
3200
3219
|
if (!isRecord(requestValue)) throw new Error("LinkedIn contact-info request must be an object");
|
|
3201
3220
|
const method = boundedText(requestValue.method, "LinkedIn contact-info request method", 16)
|
|
3202
3221
|
.toUpperCase();
|
|
3203
|
-
if (method !== "GET"
|
|
3222
|
+
if (method !== "GET" && method !== "POST") {
|
|
3223
|
+
throw new Error("LinkedIn contact-info reads require GET or the reviewed navigation POST");
|
|
3224
|
+
}
|
|
3204
3225
|
const rawUrl = requestValue.url;
|
|
3205
3226
|
if (!(rawUrl instanceof URL) && typeof rawUrl !== "string") {
|
|
3206
3227
|
throw new Error("LinkedIn contact-info request URL is invalid");
|
|
@@ -3212,6 +3233,25 @@ export function assertLinkedInWebR1RequestAllowed(
|
|
|
3212
3233
|
&& url.password === ""
|
|
3213
3234
|
&& url.hash === ""
|
|
3214
3235
|
) {
|
|
3236
|
+
if (method === "POST") {
|
|
3237
|
+
const queryNames = [...url.searchParams.keys()];
|
|
3238
|
+
if (
|
|
3239
|
+
url.pathname === "/flagship-web/rsc-action/actions/navigation"
|
|
3240
|
+
&& queryNames.length === 2
|
|
3241
|
+
&& queryNames[0] === "screenId"
|
|
3242
|
+
&& queryNames[1] === "sduiid"
|
|
3243
|
+
&& url.searchParams.get("screenId")
|
|
3244
|
+
=== "com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay"
|
|
3245
|
+
&& url.searchParams.getAll("screenId").length === 1
|
|
3246
|
+
&& url.searchParams.getAll("sduiid").length === 1
|
|
3247
|
+
) {
|
|
3248
|
+
const sduiid = url.searchParams.get("sduiid");
|
|
3249
|
+
if (
|
|
3250
|
+
sduiid === "com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay"
|
|
3251
|
+
) return;
|
|
3252
|
+
}
|
|
3253
|
+
throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
|
|
3254
|
+
}
|
|
3215
3255
|
if (url.search === "" && url.pathname === "/voyager/api/me") return;
|
|
3216
3256
|
if (url.search === "" && /^\/in\/[A-Za-z0-9][A-Za-z0-9_-]{1,99}\/$/u.test(url.pathname)) {
|
|
3217
3257
|
linkedInPersonalProfileTarget(url.href);
|
|
@@ -3242,6 +3282,10 @@ export function assertLinkedInWebR1RequestAllowed(
|
|
|
3242
3282
|
)
|
|
3243
3283
|
) return;
|
|
3244
3284
|
}
|
|
3285
|
+
if (
|
|
3286
|
+
url.search === ""
|
|
3287
|
+
&& /^\/in\/[A-Za-z0-9][A-Za-z0-9_-]{1,99}\/overlay\/contact-info\/$/u.test(url.pathname)
|
|
3288
|
+
) return;
|
|
3245
3289
|
}
|
|
3246
3290
|
throw new Error("LinkedIn contact-info request escaped its exact reviewed route");
|
|
3247
3291
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Exact admitted native bytes and pinned public PhoneNumberKit resources.
|
|
2
|
+
export const MESSAGING_NATIVE_ARTIFACTS = Object.freeze({
|
|
3
|
+
"imessage": {
|
|
4
|
+
"file": "imsg-darwin-arm64.gz",
|
|
5
|
+
"sha256": "46c4c73c81c7db2d516c2d467c66aff03c73de196996d646bc8afce0ea85cff6",
|
|
6
|
+
"bytes": 4525960,
|
|
7
|
+
"compressedSha256": "12861bdb93779405f7cc4cb4a47fc1d6ea3bf2fc8cec5ed69f2197ce96804532",
|
|
8
|
+
"compressedBytes": 1490956
|
|
9
|
+
},
|
|
10
|
+
"whatsapp": {
|
|
11
|
+
"file": "wacli-darwin-arm64.gz",
|
|
12
|
+
"sha256": "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea",
|
|
13
|
+
"bytes": 21963810,
|
|
14
|
+
"compressedSha256": "1c1650d6c79b74db7f8f335b4746398c802031260a90468312dcaac0374a5166",
|
|
15
|
+
"compressedBytes": 7682949
|
|
16
|
+
},
|
|
17
|
+
"phoneMetadata": {
|
|
18
|
+
"file": "phone-number-metadata.json.gz",
|
|
19
|
+
"sha256": "cbfe80ee5ee9901f46893a4d2c353d5eb513a21e62529ee5dfaa4e94b4460d83",
|
|
20
|
+
"bytes": 365770,
|
|
21
|
+
"compressedSha256": "27c5818bae151e945f0047fdc5a73f87a44ba678a4eb7343d1f56ef8a2b77e6f",
|
|
22
|
+
"compressedBytes": 51131
|
|
23
|
+
},
|
|
24
|
+
"phonePrivacy": {
|
|
25
|
+
"file": "phone-number-privacy.plist.gz",
|
|
26
|
+
"sha256": "561040f7a52952f75d02d4b6758382ff48c563d6f25c84a09f1a655f6dc60ff8",
|
|
27
|
+
"bytes": 372,
|
|
28
|
+
"compressedSha256": "83585010417bc61a2262f3c051eba0ab275987b02c824184c556f98412db4405",
|
|
29
|
+
"compressedBytes": 240
|
|
30
|
+
},
|
|
31
|
+
"phoneInfo": {
|
|
32
|
+
"file": "phone-number-info.plist.gz",
|
|
33
|
+
"sha256": "161f6c4a3ceaee9ab2ce30e33c80e605a11e196c53e9791f87341e01bf80d7e9",
|
|
34
|
+
"bytes": 532,
|
|
35
|
+
"compressedSha256": "6b41b44fe783ea873ef4a4a90bad1f554f474c0dc46735a95f41ff1f5bfb0729",
|
|
36
|
+
"compressedBytes": 287
|
|
37
|
+
}
|
|
38
|
+
} as const);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, link, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { gunzipSync } from "node:zlib";
|
|
8
|
+
import { ensurePrivateStateDirectory } from "../storage";
|
|
9
|
+
import { MESSAGING_NATIVE_ARTIFACTS } from "./messaging-native-artifacts";
|
|
10
|
+
|
|
11
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
12
|
+
type Asset = keyof typeof MESSAGING_NATIVE_ARTIFACTS;
|
|
13
|
+
const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
const resources = [
|
|
15
|
+
["phoneMetadata", "Contents/Resources/PhoneNumberMetadata.json"],
|
|
16
|
+
["phonePrivacy", "Contents/Resources/PrivacyInfo.xcprivacy"],
|
|
17
|
+
["phoneInfo", "Contents/Info.plist"],
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export async function readBundledMessagingAsset(asset: Asset): Promise<Buffer> {
|
|
21
|
+
const expected = MESSAGING_NATIVE_ARTIFACTS[asset];
|
|
22
|
+
const candidates = [new URL(`../assets/messaging-runtime/${expected.file}`, import.meta.url), new URL(`../src/assets/messaging-runtime/${expected.file}`, import.meta.url)];
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
const path = fileURLToPath(candidate);
|
|
25
|
+
let handle;
|
|
26
|
+
try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
27
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error("Bundled messaging asset is unreadable"); }
|
|
28
|
+
try {
|
|
29
|
+
const before = await handle.stat();
|
|
30
|
+
if (!before.isFile() || before.size !== expected.compressedBytes || before.size > 32 * 1024 * 1024 || (before.mode & 0o022) !== 0 || ![0, process.getuid?.()].includes(before.uid) || await realpath(path) !== path) throw new Error("Bundled messaging asset has unsafe metadata");
|
|
31
|
+
const compressed = await handle.readFile(), after = await handle.stat();
|
|
32
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || compressed.length !== expected.compressedBytes || sha(compressed) !== expected.compressedSha256) throw new Error("Bundled messaging asset changed or failed its digest");
|
|
33
|
+
const bytes = gunzipSync(compressed, { maxOutputLength: expected.bytes });
|
|
34
|
+
if (bytes.length !== expected.bytes || sha(bytes) !== expected.sha256) throw new Error("Bundled messaging executable or resource failed its exact pin");
|
|
35
|
+
return bytes;
|
|
36
|
+
} finally { await handle.close(); }
|
|
37
|
+
}
|
|
38
|
+
throw new Error("The installed package omitted its pinned messaging runtime");
|
|
39
|
+
}
|
|
40
|
+
async function verifyResource(path: string, asset: Asset): Promise<void> {
|
|
41
|
+
const expected = MESSAGING_NATIVE_ARTIFACTS[asset], handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
42
|
+
try { const info = await handle.stat(); if (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600 || info.size !== expected.bytes || await realpath(path) !== path || sha(await handle.readFile()) !== expected.sha256) throw new Error("Installed iMessage resource differs from its pin"); }
|
|
43
|
+
finally { await handle.close(); }
|
|
44
|
+
}
|
|
45
|
+
export async function verifyImsgNativeResources(installDirectory: string): Promise<void> {
|
|
46
|
+
for (const [asset, relative] of resources) await verifyResource(join(installDirectory, "PhoneNumberKit_PhoneNumberKit.bundle", relative), asset);
|
|
47
|
+
}
|
|
48
|
+
export async function ensureImsgNativeResources(installDirectory: string, environment: Environment): Promise<void> {
|
|
49
|
+
for (const [asset, relative] of resources) {
|
|
50
|
+
const path = join(installDirectory, "PhoneNumberKit_PhoneNumberKit.bundle", relative), parent = dirname(path);
|
|
51
|
+
ensurePrivateStateDirectory(parent, environment);
|
|
52
|
+
try { await lstat(path); await verifyResource(path, asset); continue; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
53
|
+
const bytes = await readBundledMessagingAsset(asset), temporary = join(parent, `.install-${randomBytes(16).toString("hex")}`);
|
|
54
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
55
|
+
try {
|
|
56
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
57
|
+
try { await link(temporary, path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
58
|
+
} finally { await unlink(temporary); }
|
|
59
|
+
await verifyResource(path, asset); const directory = await open(parent, constants.O_RDONLY); try { await directory.sync(); } finally { await directory.close(); }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Owner setup only. Installing resources never pairs, opens Messages, changes
|
|
64
|
+
* OS permissions, connects a provider, or grants messaging authority. */
|
|
65
|
+
export async function installBundledMessagingRuntime(provider: "imessage" | "whatsapp", environment: Environment = process.env): Promise<{ version: string; sha256: string; alreadyPresent?: boolean }> {
|
|
66
|
+
if (process.platform !== "darwin" || process.arch !== "arm64") throw new Error("Bundled messaging runtimes require Apple silicon macOS");
|
|
67
|
+
if (provider !== "imessage" && provider !== "whatsapp") throw new Error("Unknown messaging runtime");
|
|
68
|
+
const bytes = await readBundledMessagingAsset(provider), directory = await mkdtemp(join(await realpath(tmpdir()), "wrench-messaging-install-")); await chmod(directory, 0o700);
|
|
69
|
+
const path = join(directory, "runtime"), file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o500);
|
|
70
|
+
try {
|
|
71
|
+
try { await file.writeFile(bytes); await file.sync(); } finally { await file.close(); }
|
|
72
|
+
if (provider === "imessage") { const { installReviewedImsgBinary } = await import("./imessage-direct-install"); const result = await installReviewedImsgBinary(path, environment); return { version: result.version, sha256: result.executableSha256, alreadyPresent: result.alreadyPresent }; }
|
|
73
|
+
const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime"); return await installReviewedWhatsAppAutomationBinary(path, environment);
|
|
74
|
+
} finally { await unlink(path); await rmdir(directory); }
|
|
75
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { chmod, link, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
8
|
+
import type { GhostgetAuth } from "../auth";
|
|
9
|
+
import { canonicalJson } from "../canonical-json";
|
|
10
|
+
import type { LocalCliExecutionOptions } from "../local-cli-execution";
|
|
11
|
+
import { automationDigest, automationRecord, automationText } from "../messaging-automation-validation";
|
|
12
|
+
import { startProviderPluginCleanupTrackedOperation, type ProviderPluginCleanupProofController } from "../provider-plugin-cleanup-execution";
|
|
13
|
+
import { attachLocalCliCleanupProcessGroup, captureLocalCliCleanupResource, localCliCleanupProcessGroupStatus, type LocalCliCleanupResourceIdentityV1 } from "../provider-plugin-cleanup-resource";
|
|
14
|
+
import { assertSafeStatePath, ensurePrivateStateDirectory, ghostgetStateHome } from "../storage";
|
|
15
|
+
import { validateWhatsAppStoreDirectory } from "./whatsapp-web-runtime";
|
|
16
|
+
|
|
17
|
+
export const WHATSAPP_AUTOMATION_PROTOCOL = "ghostget.whatsapp-private/1" as const;
|
|
18
|
+
export const WHATSAPP_AUTOMATION_VERSION = "0.15.0+ghostget-private.1" as const;
|
|
19
|
+
// Pinned from the exact vendored patch, full plain/FTS suites, vet, and two
|
|
20
|
+
// byte-identical builds recorded in the private transport provenance.
|
|
21
|
+
export const WHATSAPP_AUTOMATION_BINARY_SHA256 = "9b77ffb810d028fde725ca02b1451f1725b5ff5312a46a54468a9a38533d4cea";
|
|
22
|
+
const directJid = /^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u;
|
|
23
|
+
const sha = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
24
|
+
type LinkedAuth = Extract<GhostgetAuth, { kind: "linked-device-store" }>;
|
|
25
|
+
export type WhatsAppAutomationSnapshot = Readonly<{ account: string; subject: string; sourceGeneration: string; ledgerReady: boolean; connected: boolean; generation: string | null }>;
|
|
26
|
+
export type WhatsAppPrivateRequest = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; kind: "status" | "text" | "file" | "react" | "sticker" | "poll"; requestId: string; generation: string; account: string; to: string; message: string; file: string; filename: string; mime: string; id: string; reaction: string; question: string; options: readonly string[]; selectable: number }>;
|
|
27
|
+
export type WhatsAppPrivateResponse = Readonly<{ protocol: typeof WHATSAPP_AUTOMATION_PROTOCOL; requestId: string; generation: string; account: string; state: "ready" | "accepted" | "not-started" | "indeterminate"; to: string; messageId: string; connected: boolean }>;
|
|
28
|
+
export interface WhatsAppAutomationRuntime {
|
|
29
|
+
read<T>(auth: GhostgetAuth, work: (database: Database, snapshot: WhatsAppAutomationSnapshot) => T, signal?: AbortSignal): Promise<T>;
|
|
30
|
+
start(auth: GhostgetAuth, beforeSpawn: () => Promise<void>, signal?: AbortSignal): Promise<void>;
|
|
31
|
+
request(request: WhatsAppPrivateRequest, signal?: AbortSignal, beforeWrite?: () => Promise<void>): Promise<WhatsAppPrivateResponse>;
|
|
32
|
+
stage(bytes: Uint8Array, digest: string): Promise<{ path: string; close(): Promise<void> }>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function linked(auth: GhostgetAuth): LinkedAuth {
|
|
37
|
+
if (auth.kind !== "linked-device-store" || auth.provider !== "whatsapp" || !auth.subject) throw new Error("A bound WhatsApp linked-device account is required");
|
|
38
|
+
return auth;
|
|
39
|
+
}
|
|
40
|
+
function accountSubject(jid: string): string {
|
|
41
|
+
if (!directJid.test(jid)) throw new Error("Unsupported WhatsApp account identity");
|
|
42
|
+
return `whatsapp:${jid.endsWith("@lid") ? "lid" : "pn"}:${jid.split("@")[0]}`;
|
|
43
|
+
}
|
|
44
|
+
function accountJid(value: unknown): string {
|
|
45
|
+
const jid = automationText(value, 128);
|
|
46
|
+
// The session table stores the current device suffix. Only this account
|
|
47
|
+
// coordinate is normalized; recipient JIDs are never rewritten.
|
|
48
|
+
const normalized = jid.replace(/:[0-9]{1,5}(?=@(?:s\.whatsapp\.net|lid)$)/u, "");
|
|
49
|
+
accountSubject(normalized); return normalized;
|
|
50
|
+
}
|
|
51
|
+
async function privateFile(path: string): Promise<{ dev: string; ino: string; birthtimeNs: string }> {
|
|
52
|
+
const info = await lstat(path, { bigint: true });
|
|
53
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== BigInt(process.getuid!()) || info.nlink !== 1n || (info.mode & 0o777n) !== 0o600n || info.size < 1n || info.size > 4_294_967_296n || info.birthtimeNs <= 0n || await realpath(path) !== path) throw new Error("Unsafe WhatsApp source file");
|
|
54
|
+
return { dev: String(info.dev), ino: String(info.ino), birthtimeNs: String(info.birthtimeNs) };
|
|
55
|
+
}
|
|
56
|
+
async function binaryBytes(path: string): Promise<Buffer> {
|
|
57
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
58
|
+
try {
|
|
59
|
+
const before = await file.stat();
|
|
60
|
+
if (!before.isFile() || before.nlink !== 1 || before.size < 1 || before.size > 128 * 1024 * 1024 || (before.mode & 0o022) !== 0 || (before.mode & 0o111) === 0 || ![0, process.getuid!()].includes(before.uid)) throw new Error("Unsafe WhatsApp automation executable");
|
|
61
|
+
const bytes = await file.readFile(), after = await file.stat();
|
|
62
|
+
if (bytes.length !== before.size || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error("WhatsApp automation executable changed");
|
|
63
|
+
return bytes;
|
|
64
|
+
} finally { await file.close(); }
|
|
65
|
+
}
|
|
66
|
+
async function binaryDigest(path: string): Promise<string> { return createHash("sha256").update(await binaryBytes(path)).digest("hex"); }
|
|
67
|
+
export function whatsappAutomationBinaryPath(environment: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
68
|
+
if (process.platform !== "darwin" || process.arch !== "arm64") throw new Error("The reviewed WhatsApp automation build requires Apple silicon macOS");
|
|
69
|
+
return join(ghostgetStateHome(environment), "tools", "wacli-private", WHATSAPP_AUTOMATION_VERSION, "darwin-arm64", "wacli");
|
|
70
|
+
}
|
|
71
|
+
export async function resolveWhatsAppAutomationBinary(environment: Readonly<Record<string, string | undefined>> = process.env): Promise<string> {
|
|
72
|
+
const path = whatsappAutomationBinaryPath(environment);
|
|
73
|
+
if (await realpath(path) !== path || await binaryDigest(path) !== WHATSAPP_AUTOMATION_BINARY_SHA256) throw new Error("The reviewed one-attempt WhatsApp automation runtime is not installed");
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
/** Owner setup installs only the admitted bytes. It never replaces a different
|
|
77
|
+
* existing runtime or starts the linked-device session. */
|
|
78
|
+
export async function installReviewedWhatsAppAutomationBinary(source: string, environment: Readonly<Record<string, string | undefined>> = process.env): Promise<{ version: string; sha256: string }> {
|
|
79
|
+
if (!isAbsolute(source)) throw new Error("WhatsApp automation install source must be absolute");
|
|
80
|
+
const bytes = await binaryBytes(source);
|
|
81
|
+
if (createHash("sha256").update(bytes).digest("hex") !== WHATSAPP_AUTOMATION_BINARY_SHA256) throw new Error("WhatsApp automation install source does not match the reviewed build");
|
|
82
|
+
const destination = whatsappAutomationBinaryPath(environment);
|
|
83
|
+
ensurePrivateStateDirectory(dirname(destination), environment); assertSafeStatePath(destination, environment, false);
|
|
84
|
+
try { await lstat(destination); if (await binaryDigest(destination) !== WHATSAPP_AUTOMATION_BINARY_SHA256) throw new Error("WhatsApp automation install destination contains different bytes"); return { version: WHATSAPP_AUTOMATION_VERSION, sha256: WHATSAPP_AUTOMATION_BINARY_SHA256 }; }
|
|
85
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
86
|
+
const temporary = join(dirname(destination), `.wacli-install-${randomBytes(16).toString("hex")}`);
|
|
87
|
+
const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o500);
|
|
88
|
+
try {
|
|
89
|
+
try { await file.writeFile(bytes); await file.sync(); } finally { await file.close(); }
|
|
90
|
+
try { await link(temporary, destination); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
91
|
+
} finally { await unlink(temporary); }
|
|
92
|
+
const directory = await open(dirname(destination), constants.O_RDONLY); try { await directory.sync(); } finally { await directory.close(); }
|
|
93
|
+
await resolveWhatsAppAutomationBinary(environment);
|
|
94
|
+
return { version: WHATSAPP_AUTOMATION_VERSION, sha256: WHATSAPP_AUTOMATION_BINARY_SHA256 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseWhatsAppPrivateResponse(value: unknown): WhatsAppPrivateResponse {
|
|
98
|
+
const row = automationRecord(value, ["protocol", "requestId", "generation", "account", "state", "to", "messageId", "connected"]);
|
|
99
|
+
if (row.protocol !== WHATSAPP_AUTOMATION_PROTOCOL || !["ready", "accepted", "not-started", "indeterminate"].includes(String(row.state)) || typeof row.connected !== "boolean") throw new Error("Unsupported WhatsApp transport response");
|
|
100
|
+
for (const field of ["requestId", "to", "messageId"] as const) if (typeof row[field] !== "string" || Buffer.byteLength(row[field]) > 256 || /[\u0000-\u001f\u007f]/u.test(row[field])) throw new Error("Invalid WhatsApp receipt field");
|
|
101
|
+
const generation = automationDigest(row.generation), account = automationText(row.account, 128);
|
|
102
|
+
if (!directJid.test(account) || row.requestId !== "" && !/^[a-f0-9]{64}$/u.test(row.requestId as string) || row.to !== "" && !directJid.test(row.to as string) || row.state === "accepted" && (row.messageId === "" || row.requestId === "" || row.to === "")) throw new Error("Invalid WhatsApp receipt identity");
|
|
103
|
+
return { protocol: WHATSAPP_AUTOMATION_PROTOCOL, requestId: row.requestId as string, generation, account, state: row.state as WhatsAppPrivateResponse["state"], to: row.to as string, messageId: row.messageId as string, connected: row.connected };
|
|
104
|
+
}
|
|
105
|
+
const blankStatus = (): WhatsAppPrivateRequest => ({ protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "status", requestId: "", generation: "", account: "", to: "", message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 });
|
|
106
|
+
async function socketRequest(path: string, identity: { dev: number; ino: number }, request: WhatsAppPrivateRequest, signal?: AbortSignal, beforeWrite?: () => Promise<void>): Promise<WhatsAppPrivateResponse> {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
const info = await lstat(path);
|
|
109
|
+
if (!info.isSocket() || info.isSymbolicLink() || info.uid !== process.getuid!() || (info.mode & 0o777) !== 0o600 || info.dev !== identity.dev || info.ino !== identity.ino) throw new Error("WhatsApp transport ownership changed");
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const socket = createConnection({ path }); let bytes = Buffer.alloc(0), settled = false;
|
|
112
|
+
const finish = (error?: Error, response?: WhatsAppPrivateResponse): void => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); socket.destroy(); error ? reject(error) : resolve(response!); };
|
|
113
|
+
const abort = (): void => finish(new Error("WhatsApp transport request was interrupted"));
|
|
114
|
+
const timer = setTimeout(() => finish(new Error("WhatsApp transport response deadline exceeded")), 55_000);
|
|
115
|
+
signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) { abort(); return; }
|
|
116
|
+
socket.on("error", () => finish(new Error("WhatsApp transport connection failed")));
|
|
117
|
+
socket.on("close", () => { if (!settled) finish(new Error("WhatsApp transport closed without a receipt")); });
|
|
118
|
+
socket.on("connect", () => { void (async () => {
|
|
119
|
+
await beforeWrite?.(); signal?.throwIfAborted(); if (settled) return;
|
|
120
|
+
const current = await lstat(path);
|
|
121
|
+
if (!current.isSocket() || current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino || current.uid !== process.getuid!() || (current.mode & 0o777) !== 0o600) throw new Error("WhatsApp socket changed after connection");
|
|
122
|
+
signal?.throwIfAborted(); if (settled) return;
|
|
123
|
+
const payload = Buffer.from(JSON.stringify(request) + "\n"); if (payload.length > 1_048_576) finish(new Error("WhatsApp request exceeds its byte bound")); else socket.write(payload);
|
|
124
|
+
})().catch(() => finish(new Error("WhatsApp request admission changed before writing"))); });
|
|
125
|
+
socket.on("data", chunk => {
|
|
126
|
+
if (!Buffer.isBuffer(chunk)) { finish(new Error("WhatsApp response was not bytes")); return; }
|
|
127
|
+
if (bytes.length + chunk.length > 4096) { finish(new Error("WhatsApp response exceeds its byte bound")); return; }
|
|
128
|
+
bytes = Buffer.concat([bytes, chunk]); const newline = bytes.indexOf(10);
|
|
129
|
+
if (newline < 0) return;
|
|
130
|
+
try {
|
|
131
|
+
if (newline !== bytes.length - 1) throw new Error("Trailing WhatsApp response data");
|
|
132
|
+
const response = parseWhatsAppPrivateResponse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))));
|
|
133
|
+
if (request.kind === "status" ? response.state !== "ready" || response.requestId !== "" || response.to !== "" || response.messageId !== "" : response.requestId !== request.requestId || response.to !== request.to || response.account !== request.account || response.generation !== request.generation || response.state === "ready") throw new Error("WhatsApp response did not bind its request");
|
|
134
|
+
finish(undefined, response);
|
|
135
|
+
} catch { finish(new Error("WhatsApp transport returned a malformed receipt")); }
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
140
|
+
|
|
141
|
+
/** Owns only an explicitly started, exact pinned child. Existing sockets are
|
|
142
|
+
* never adopted or removed. A failed cleanup retains its durable resource. */
|
|
143
|
+
export function createWhatsAppAutomationRuntime(execution: Pick<LocalCliExecutionOptions, "registerCleanupBarrier" | "environment">): WhatsAppAutomationRuntime {
|
|
144
|
+
let child: Bun.Subprocess<"ignore", "ignore", "ignore"> | undefined;
|
|
145
|
+
let resource: LocalCliCleanupResourceIdentityV1 | undefined, cleanup: ProviderPluginCleanupProofController | undefined;
|
|
146
|
+
let socket: { path: string; dev: number; ino: number } | undefined;
|
|
147
|
+
let authHash: string | undefined, root: string | undefined, closed = false, closing: Promise<void> | undefined;
|
|
148
|
+
const staged = new Set<string>();
|
|
149
|
+
const runtime: WhatsAppAutomationRuntime = {
|
|
150
|
+
async read(auth, work, signal) {
|
|
151
|
+
signal?.throwIfAborted(); if (closed) throw new Error("WhatsApp automation runtime is closed");
|
|
152
|
+
const selected = linked(auth), store = await validateWhatsAppStoreDirectory(selected.path, "projection");
|
|
153
|
+
const messagesPath = join(store, "wacli.db"), sessionPath = join(store, "session.db");
|
|
154
|
+
const before = [await privateFile(messagesPath), await privateFile(sessionPath)];
|
|
155
|
+
const session = new Database(sessionPath, { readonly: true, strict: true });
|
|
156
|
+
let account: string;
|
|
157
|
+
try { const devices = session.query("SELECT jid FROM whatsmeow_device LIMIT 2").all(); if (devices.length !== 1) throw new Error("WhatsApp session must select exactly one account"); account = accountJid(automationRecord(devices[0], ["jid"]).jid); } finally { session.close(); }
|
|
158
|
+
const subject = accountSubject(account); if (selected.subject !== subject) throw new Error("WhatsApp linked-device account changed");
|
|
159
|
+
let connected = false, generation: string | null = null;
|
|
160
|
+
if (socket && child && child.exitCode === null && authHash === sha(auth)) { const status = await runtime.request(blankStatus(), signal); if (status.account !== account) throw new Error("WhatsApp connection account changed"); connected = status.connected; generation = status.generation; }
|
|
161
|
+
const database = new Database(messagesPath, { readonly: true, strict: true });
|
|
162
|
+
try {
|
|
163
|
+
database.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000; BEGIN");
|
|
164
|
+
const available = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='ghostget_automation_state'").get();
|
|
165
|
+
let ledger: string | null = null;
|
|
166
|
+
if (available) { const state = automationRecord(database.query("SELECT version,generation FROM ghostget_automation_state WHERE singleton=1").get(), ["version", "generation"]); if (state.version !== 1) throw new Error("Unsupported WhatsApp event schema"); ledger = automationDigest(state.generation); }
|
|
167
|
+
const snapshot = { account, subject, sourceGeneration: sha({ files: before, ledger }), ledgerReady: ledger !== null, connected, generation };
|
|
168
|
+
const result = work(database, snapshot); database.exec("COMMIT");
|
|
169
|
+
if (sha(before) !== sha([await privateFile(messagesPath), await privateFile(sessionPath)])) throw new Error("WhatsApp database generation changed during the read");
|
|
170
|
+
signal?.throwIfAborted(); return result;
|
|
171
|
+
} finally { database.close(); }
|
|
172
|
+
},
|
|
173
|
+
async start(auth, beforeSpawn, signal) {
|
|
174
|
+
if (closed || child || root) throw new Error("WhatsApp automation has already been started or closed");
|
|
175
|
+
if (!execution.registerCleanupBarrier) throw new Error("WhatsApp automation requires durable process custody");
|
|
176
|
+
const selected = linked(auth), store = await validateWhatsAppStoreDirectory(selected.path, "projection");
|
|
177
|
+
await runtime.read(auth, () => undefined, signal);
|
|
178
|
+
const binary = await resolveWhatsAppAutomationBinary(execution.environment);
|
|
179
|
+
await startProviderPluginCleanupTrackedOperation(execution.registerCleanupBarrier, async (publish, controller) => {
|
|
180
|
+
cleanup = controller;
|
|
181
|
+
try {
|
|
182
|
+
root = await mkdtemp(join(await realpath(tmpdir()), "wrench-whatsapp-automation-")); await chmod(root, 0o700);
|
|
183
|
+
resource = captureLocalCliCleanupResource(root); publish?.(resource);
|
|
184
|
+
const socketPath = join(root, ".s");
|
|
185
|
+
if (Buffer.byteLength(socketPath) > 100) throw new Error("WhatsApp private runtime directory exceeds the Unix socket path bound");
|
|
186
|
+
await beforeSpawn(); signal?.throwIfAborted();
|
|
187
|
+
if (await binaryDigest(binary) !== WHATSAPP_AUTOMATION_BINARY_SHA256) throw new Error("WhatsApp automation binary changed before launch");
|
|
188
|
+
child = Bun.spawn([binary, "--store", store, "--json", "--full", "sync", "--follow", "--presence-mode", "quiet", "--max-reconnect", "1m", "--max-messages", "200000", "--max-db-size", "2GB", "--ghostget-private-transport", "--ghostget-private-socket", socketPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true, env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "C.UTF-8", LC_ALL: "C.UTF-8" } });
|
|
189
|
+
resource = attachLocalCliCleanupProcessGroup(resource, child.pid); publish?.(resource); authHash = sha(auth);
|
|
190
|
+
const until = Date.now() + 30_000;
|
|
191
|
+
while (Date.now() < until) {
|
|
192
|
+
signal?.throwIfAborted(); if (child.exitCode !== null) throw new Error("WhatsApp connection exited during startup");
|
|
193
|
+
try { const info = await lstat(socketPath); if (!info.isSocket() || info.uid !== process.getuid!() || (info.mode & 0o777) !== 0o600) throw new Error("Unsafe WhatsApp private socket"); socket = { path: socketPath, dev: info.dev, ino: info.ino }; const status = await runtime.request(blankStatus(), signal); if (status.connected && accountSubject(status.account) === selected.subject) return; throw new Error("WhatsApp connection did not match the selected account"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
194
|
+
await sleep(50);
|
|
195
|
+
}
|
|
196
|
+
throw new Error("WhatsApp connection did not become ready within its deadline");
|
|
197
|
+
} catch (error) { await runtime.close(); throw error; }
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
async request(request, signal, beforeWrite) {
|
|
201
|
+
if (closed || !socket || !child || child.exitCode !== null) throw new Error("The owned WhatsApp connection is unavailable");
|
|
202
|
+
return socketRequest(socket.path, socket, request, signal, beforeWrite);
|
|
203
|
+
},
|
|
204
|
+
async stage(bytes, digest) {
|
|
205
|
+
const snapshot = Buffer.from(bytes);
|
|
206
|
+
if (closed || !root || !child || child.exitCode !== null || snapshot.byteLength < 1 || snapshot.byteLength > 20 * 1024 * 1024 || createHash("sha256").update(snapshot).digest("hex") !== automationDigest(digest)) throw new Error("WhatsApp asset is unavailable or changed");
|
|
207
|
+
const path = join(root, `asset-${randomBytes(16).toString("hex")}`), file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
208
|
+
try { await file.writeFile(snapshot); await file.sync(); } finally { await file.close(); }
|
|
209
|
+
const identity = await lstat(path); staged.add(path);
|
|
210
|
+
return { path, async close() { if (!staged.has(path)) return; const current = await lstat(path); if (current.dev !== identity.dev || current.ino !== identity.ino || !current.isFile() || current.nlink !== 1) throw new Error("WhatsApp staged asset changed"); await unlink(path); staged.delete(path); } };
|
|
211
|
+
},
|
|
212
|
+
close() {
|
|
213
|
+
if (closing) return closing;
|
|
214
|
+
closed = true;
|
|
215
|
+
closing = (async () => {
|
|
216
|
+
try {
|
|
217
|
+
if (child) {
|
|
218
|
+
if (child.exitCode === null) { try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } }
|
|
219
|
+
const until = Date.now() + 55_000;
|
|
220
|
+
while (child.exitCode === null && Date.now() < until) await sleep(20);
|
|
221
|
+
if (child.exitCode === null) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } }
|
|
222
|
+
const reapUntil = Date.now() + 5_000;
|
|
223
|
+
while (child.exitCode === null && Date.now() < reapUntil) await sleep(20);
|
|
224
|
+
if (child.exitCode === null) throw new Error("WhatsApp process did not join");
|
|
225
|
+
await child.exited;
|
|
226
|
+
if (!resource || localCliCleanupProcessGroupStatus(resource) !== "quiescent") throw new Error("WhatsApp process group cleanup is unverified");
|
|
227
|
+
}
|
|
228
|
+
if (socket) {
|
|
229
|
+
try { const current = await lstat(socket.path); if (!current.isSocket() || current.dev !== socket.dev || current.ino !== socket.ino || current.uid !== process.getuid!()) throw new Error("WhatsApp private socket changed before cleanup"); await unlink(socket.path); }
|
|
230
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
231
|
+
}
|
|
232
|
+
if (staged.size > 0) throw new Error("WhatsApp assets are still in use");
|
|
233
|
+
if (root) await rmdir(root);
|
|
234
|
+
cleanup?.verified();
|
|
235
|
+
} catch (error) { cleanup?.unsafe(error); throw error; }
|
|
236
|
+
})(); return closing;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
return runtime;
|
|
240
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { GhostgetAuth } from "../auth";
|
|
4
|
+
import { canonicalJson } from "../canonical-json";
|
|
5
|
+
import type { LocalCliExecutionOptions } from "../local-cli-execution";
|
|
6
|
+
import type { AutomationAction, AutomationConversation, AutomationCoordinate, AutomationIdentity, AutomationMessage, AutomationProviderStatus, AutomationProviderSendResult, MessagingAutomationProvider } from "../messaging-automation-types";
|
|
7
|
+
import { AUTOMATION_ACTION_KINDS, automationArray, automationDigest, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationCoordinate, parseAutomationIdentity } from "../messaging-automation-validation";
|
|
8
|
+
import { createWhatsAppAutomationRuntime, WHATSAPP_AUTOMATION_PROTOCOL, type WhatsAppAutomationRuntime, type WhatsAppAutomationSnapshot, type WhatsAppPrivateRequest } from "./whatsapp-automation-runtime";
|
|
9
|
+
|
|
10
|
+
export type WhatsAppAutomationOperation = "inspect" | "start" | "conversations" | "resolve" | "history" | "events" | AutomationAction["kind"];
|
|
11
|
+
export type WhatsAppAutomationAdmission = Readonly<{ auth: GhostgetAuth; accountIdentity: string; implementationIdentity: string }>;
|
|
12
|
+
export type WhatsAppAutomationOptions = Readonly<{
|
|
13
|
+
authorize(operation: WhatsAppAutomationOperation, signal?: AbortSignal): Promise<WhatsAppAutomationAdmission>;
|
|
14
|
+
execution: Pick<LocalCliExecutionOptions, "registerCleanupBarrier" | "environment">;
|
|
15
|
+
resolveAsset(assetId: string, signal?: AbortSignal): Promise<Readonly<{ bytes: Uint8Array; sha256: string }>>;
|
|
16
|
+
/** Internal deterministic test seam, never populated from public input. */
|
|
17
|
+
runtime?: WhatsAppAutomationRuntime;
|
|
18
|
+
}>;
|
|
19
|
+
export interface WhatsAppAutomationProvider extends MessagingAutomationProvider { start(signal?: AbortSignal): Promise<void> }
|
|
20
|
+
const sha = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
21
|
+
type Coordinate = Extract<AutomationCoordinate, { provider: "whatsapp" }>;
|
|
22
|
+
function coordinate(value: unknown): Coordinate { const result = parseAutomationCoordinate(value); if (result.provider !== "whatsapp") throw new Error("WhatsApp cannot operate another messaging network"); return result; }
|
|
23
|
+
function identity(admission: WhatsAppAutomationAdmission, snapshot: WhatsAppAutomationSnapshot): AutomationIdentity { return parseAutomationIdentity({ provider: "whatsapp", authId: admission.auth.id, accountIdentity: admission.accountIdentity, accountSubject: snapshot.subject, implementationIdentity: admission.implementationIdentity, sourceGeneration: snapshot.sourceGeneration }); }
|
|
24
|
+
function conversation(value: unknown): AutomationConversation {
|
|
25
|
+
const row = automationRecord(value, ["jid", "kind", "name"]), selected = coordinate({ provider: "whatsapp", conversationJid: row.jid });
|
|
26
|
+
if (row.kind !== "dm") throw new Error("Only direct WhatsApp conversations can be enrolled");
|
|
27
|
+
return { coordinate: selected, title: row.name === null || row.name === "" ? null : automationText(row.name, 512), kind: "single", participants: [selected.conversationJid] };
|
|
28
|
+
}
|
|
29
|
+
function exact(database: Database, selected: Coordinate, account: string): AutomationConversation {
|
|
30
|
+
if (selected.conversationJid === account) throw new Error("Self messaging requires a separate contract");
|
|
31
|
+
return conversation(database.query("SELECT jid,kind,name FROM chats WHERE jid=? AND kind='dm'").get(selected.conversationJid));
|
|
32
|
+
}
|
|
33
|
+
const messageColumns = "chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,quoted_msg_id,reaction_to_id,reaction_emoji,media_type,filename,mime_type,file_length,revoked,deleted_for_me";
|
|
34
|
+
const nullableText = (value: unknown, maximum: number): string | null => value === null || value === "" ? null : automationText(value, maximum, true);
|
|
35
|
+
function message(value: unknown): AutomationMessage {
|
|
36
|
+
const row = automationRecord(value, ["kind", ...messageColumns.split(",")]);
|
|
37
|
+
const selected = coordinate({ provider: "whatsapp", conversationJid: row.chat_jid });
|
|
38
|
+
const id = automationText(row.msg_id, 256), seconds = automationInteger(row.ts, 0, 253_402_300_799);
|
|
39
|
+
if (row.from_me !== 0 && row.from_me !== 1 || row.revoked !== 0 && row.revoked !== 1 || row.deleted_for_me !== 0 && row.deleted_for_me !== 1 || !["message", "reaction", "edit", "delete"].includes(String(row.kind))) throw new Error("Invalid WhatsApp message projection");
|
|
40
|
+
nullableText(row.sender_jid, 128); nullableText(row.reaction_emoji, 64);
|
|
41
|
+
const deleted = row.kind === "delete" || row.revoked === 1 || row.deleted_for_me === 1;
|
|
42
|
+
const text = deleted ? null : row.kind === "reaction" ? nullableText(row.reaction_emoji, 64) : nullableText(row.text || row.display_text, 65_536);
|
|
43
|
+
const media = nullableText(row.media_type, 128);
|
|
44
|
+
return { id, coordinate: selected, direction: row.from_me === 1 ? "outgoing" : "incoming", occurredAt: new Date(seconds * 1000).toISOString(), text, kind: deleted ? "delete" : row.kind as AutomationMessage["kind"], relatedMessageId: nullableText(row.reaction_to_id || row.quoted_msg_id, 256), attachments: media && !deleted ? [{ name: nullableText(row.filename, 255), mimeType: nullableText(row.mime_type, 128), sizeBytes: row.file_length === null ? null : automationInteger(row.file_length, 0, Number.MAX_SAFE_INTEGER) }] : [] };
|
|
45
|
+
}
|
|
46
|
+
function status(current: AutomationIdentity, snapshot: WhatsAppAutomationSnapshot): AutomationProviderStatus {
|
|
47
|
+
const supported = new Set<AutomationAction["kind"]>(["text", "attachment", "reaction", "sticker", "link", "poll"]);
|
|
48
|
+
const actions = Object.fromEntries(AUTOMATION_ACTION_KINDS.map(kind => [kind, { available: snapshot.connected && snapshot.ledgerReady && supported.has(kind), reason: !supported.has(kind) ? "The pinned WhatsApp transport has no operation for this experience." : !snapshot.connected ? "Start the selected WhatsApp linked-device connection explicitly through Ghostget." : !snapshot.ledgerReady ? "The durable message feed has not been initialized." : null }])) as AutomationProviderStatus["actions"];
|
|
49
|
+
return { identity: current, connected: snapshot.connected, events: { available: snapshot.connected && snapshot.ledgerReady, reason: snapshot.connected && snapshot.ledgerReady ? null : "The owned WhatsApp connection and its durable feed must be ready." }, actions };
|
|
50
|
+
}
|
|
51
|
+
type Cursor = { version: 1; identity: string; scope: string; sequence: number; anchor: string | null };
|
|
52
|
+
function encodeCursor(value: Cursor): string { return Buffer.from(canonicalJson(value)).toString("base64url"); }
|
|
53
|
+
function parseCursor(value: string | null, current: AutomationIdentity, coordinates: readonly Coordinate[]): Cursor {
|
|
54
|
+
const initial: Cursor = { version: 1, identity: sha(current), scope: sha(coordinates), sequence: 0, anchor: null };
|
|
55
|
+
if (value === null) return initial;
|
|
56
|
+
automationText(value, 16_384); const decoded = Buffer.from(value, "base64url");
|
|
57
|
+
if (decoded.toString("base64url") !== value) throw new Error("Noncanonical WhatsApp cursor");
|
|
58
|
+
const row = automationRecord(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decoded)), ["version", "identity", "scope", "sequence", "anchor"]);
|
|
59
|
+
if (row.version !== 1 || row.identity !== initial.identity || row.scope !== initial.scope) throw new Error("WhatsApp cursor identity or scope changed");
|
|
60
|
+
return { ...initial, sequence: automationInteger(row.sequence, 0, Number.MAX_SAFE_INTEGER), anchor: row.anchor === null ? null : automationDigest(row.anchor) };
|
|
61
|
+
}
|
|
62
|
+
function ledgerBounds(database: Database): { first: number; last: number } {
|
|
63
|
+
const row = automationRecord(database.query("SELECT COALESCE(MIN(sequence),0) AS first,COALESCE(MAX(sequence),0) AS last FROM ghostget_automation_events").get(), ["first", "last"]);
|
|
64
|
+
return { first: automationInteger(row.first, 0, Number.MAX_SAFE_INTEGER), last: automationInteger(row.last, 0, Number.MAX_SAFE_INTEGER) };
|
|
65
|
+
}
|
|
66
|
+
function ledgerAnchor(database: Database, sequence: number): string | null {
|
|
67
|
+
if (sequence === 0) return null;
|
|
68
|
+
const row = database.query(`SELECT sequence,kind,${messageColumns} FROM ghostget_automation_events WHERE sequence=?`).get(sequence);
|
|
69
|
+
return row === null ? null : sha(row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Concrete pinned linked-device adapter. Pairing remains Ghostget-owned setup;
|
|
73
|
+
* no QR, implicit synchronization, account fallback, or application send retry. */
|
|
74
|
+
export function createWhatsAppAutomationProvider(options: WhatsAppAutomationOptions): WhatsAppAutomationProvider {
|
|
75
|
+
const runtime = options.runtime ?? createWhatsAppAutomationRuntime(options.execution);
|
|
76
|
+
const lifetime = new AbortController();
|
|
77
|
+
let closed = false, inFlight: Promise<unknown> | undefined;
|
|
78
|
+
async function run<T>(operation: WhatsAppAutomationOperation, signal: AbortSignal | undefined, work: (admission: WhatsAppAutomationAdmission, signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
79
|
+
if (closed || inFlight) throw new Error("WhatsApp automation provider is closed or busy");
|
|
80
|
+
const activeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
81
|
+
const pending = (async () => { activeSignal.throwIfAborted(); const before = await options.authorize(operation, activeSignal); const result = await work(before, activeSignal); const after = await options.authorize(operation, activeSignal); if (sha(before) !== sha(after)) throw new Error("WhatsApp account or permission changed during the operation"); return result; })();
|
|
82
|
+
inFlight = pending; try { return await pending; } finally { if (inFlight === pending) inFlight = undefined; }
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
provider: "whatsapp",
|
|
86
|
+
start(signal) { return run("start", signal, (admission, signal) => runtime.start(admission.auth, async () => { if (sha(await options.authorize("start", signal)) !== sha(admission)) throw new Error("WhatsApp synchronization permission changed before launch"); }, signal)); },
|
|
87
|
+
inspect(signal) { return run("inspect", signal, (admission, signal) => runtime.read(admission.auth, (_database, snapshot) => status(identity(admission, snapshot), snapshot), signal)); },
|
|
88
|
+
conversations(input, signal) {
|
|
89
|
+
const limit = automationInteger(input.limit, 1, 200);
|
|
90
|
+
return run("conversations", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => {
|
|
91
|
+
const rows = database.query("SELECT jid,kind,name FROM chats WHERE kind='dm' AND jid<>? ORDER BY last_message_ts DESC,jid ASC LIMIT ?").all(snapshot.account, limit + 1);
|
|
92
|
+
return { identity: identity(admission, snapshot), conversations: rows.slice(0, limit).map(conversation), complete: rows.length <= limit };
|
|
93
|
+
}, signal));
|
|
94
|
+
},
|
|
95
|
+
resolve(value, signal) { const selected = coordinate(value); return run("resolve", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => ({ identity: identity(admission, snapshot), conversation: exact(database, selected, snapshot.account) }), signal)); },
|
|
96
|
+
history(input, signal) {
|
|
97
|
+
const selected = coordinate(input.coordinate), limit = automationInteger(input.limit, 1, 200);
|
|
98
|
+
return run("history", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => {
|
|
99
|
+
if (!snapshot.ledgerReady) throw new Error("Start the owned WhatsApp connection before enrollment");
|
|
100
|
+
exact(database, selected, snapshot.account); const current = identity(admission, snapshot), bounds = ledgerBounds(database);
|
|
101
|
+
const rows = database.query(`SELECT CASE WHEN revoked=1 OR deleted_for_me=1 THEN 'delete' WHEN reaction_to_id IS NOT NULL AND reaction_to_id<>'' THEN 'reaction' ELSE 'message' END AS kind,${messageColumns} FROM messages WHERE chat_jid=? ORDER BY ts DESC,rowid DESC LIMIT ?`).all(selected.conversationJid, limit).reverse();
|
|
102
|
+
return { identity: current, messages: rows.map(message), nextCursor: encodeCursor({ version: 1, identity: sha(current), scope: sha([selected]), sequence: bounds.last, anchor: ledgerAnchor(database, bounds.last) }), caughtUp: true, gap: false };
|
|
103
|
+
}, signal));
|
|
104
|
+
},
|
|
105
|
+
events(input, signal) {
|
|
106
|
+
const coordinates = automationArray(input.coordinates, 50).map(coordinate).sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))), limit = automationInteger(input.limit, 1, 500);
|
|
107
|
+
if (!coordinates.length || new Set(coordinates.map(sha)).size !== coordinates.length) throw new Error("Distinct WhatsApp event scopes are required");
|
|
108
|
+
return run("events", signal, (admission, signal) => runtime.read(admission.auth, (database, snapshot) => {
|
|
109
|
+
if (!snapshot.ledgerReady || !snapshot.connected) throw new Error("The owned WhatsApp message feed is unavailable");
|
|
110
|
+
for (const selected of coordinates) exact(database, selected, snapshot.account);
|
|
111
|
+
const current = identity(admission, snapshot), next = parseCursor(input.cursor, current, coordinates), bounds = ledgerBounds(database);
|
|
112
|
+
if (next.sequence > bounds.last || next.sequence < bounds.first - 1 || next.sequence > 0 && ledgerAnchor(database, next.sequence) !== next.anchor) return { identity: current, messages: [], nextCursor: encodeCursor(next), caughtUp: false, gap: true };
|
|
113
|
+
const placeholders = coordinates.map(() => "?").join(",");
|
|
114
|
+
const rows = database.query(`SELECT sequence,kind,${messageColumns} FROM ghostget_automation_events WHERE sequence>? AND chat_jid IN (${placeholders}) ORDER BY sequence ASC LIMIT ?`).all(next.sequence, ...coordinates.map(value => value.conversationJid), limit + 1);
|
|
115
|
+
const page = rows.slice(0, limit).map(value => { const row = automationRecord(value, ["sequence", "kind", ...messageColumns.split(",")]); const sequence = automationInteger(row.sequence, next.sequence + 1, bounds.last); next.sequence = sequence; const { sequence: _sequence, ...projection } = row; return message(projection); });
|
|
116
|
+
const caughtUp = rows.length <= limit; if (caughtUp) next.sequence = bounds.last; next.anchor = ledgerAnchor(database, next.sequence);
|
|
117
|
+
return { identity: current, messages: page, nextCursor: encodeCursor(next), caughtUp, gap: false };
|
|
118
|
+
}, signal));
|
|
119
|
+
},
|
|
120
|
+
async send(input, signal): Promise<AutomationProviderSendResult> {
|
|
121
|
+
const selected = coordinate(input.coordinate), expected = parseAutomationIdentity(input.identity), action = parseAutomationAction(input.action), intentId = automationText(input.intentId, 256);
|
|
122
|
+
let dispatched = false, asset: Awaited<ReturnType<WhatsAppAutomationRuntime["stage"]>> | undefined;
|
|
123
|
+
try {
|
|
124
|
+
return await run(action.kind, signal, async (admission, signal) => {
|
|
125
|
+
try {
|
|
126
|
+
let request: WhatsAppPrivateRequest = { protocol: WHATSAPP_AUTOMATION_PROTOCOL, kind: "text", requestId: sha({ intentId, expected, selected, action }), generation: "", account: "", to: selected.conversationJid, message: "", file: "", filename: "", mime: "", id: "", reaction: "", question: "", options: [], selectable: 0 };
|
|
127
|
+
if (action.kind === "text") request = { ...request, message: action.text };
|
|
128
|
+
else if (action.kind === "link") request = { ...request, message: action.url };
|
|
129
|
+
else if (action.kind === "reaction") request = { ...request, kind: "react", id: action.messageId, reaction: action.remove ? "" : action.emoji };
|
|
130
|
+
else if (action.kind === "poll") request = { ...request, kind: "poll", question: action.question, options: action.options, selectable: action.maximumSelections ?? 1 };
|
|
131
|
+
else if (action.kind === "attachment" || action.kind === "sticker") {
|
|
132
|
+
if (action.kind === "sticker" && action.messageId !== null) throw new Error("WhatsApp stickers are standalone messages in this contract");
|
|
133
|
+
const resolved = await options.resolveAsset(action.assetId, signal); asset = await runtime.stage(resolved.bytes, resolved.sha256);
|
|
134
|
+
request = { ...request, kind: action.kind === "sticker" ? "sticker" : "file", file: asset.path, filename: action.kind === "attachment" ? action.name : "", mime: action.kind === "attachment" ? action.mimeType : "" };
|
|
135
|
+
} else throw new Error("The pinned WhatsApp transport does not support this action");
|
|
136
|
+
if (sha(await options.authorize(action.kind, signal)) !== sha(admission)) throw new Error("WhatsApp permission changed before dispatch");
|
|
137
|
+
request = await runtime.read(admission.auth, (database, snapshot) => {
|
|
138
|
+
const current = identity(admission, snapshot); if (sha(current) !== sha(expected) || !status(current, snapshot).actions[action.kind].available || !snapshot.generation) throw new Error("WhatsApp source identity or capability changed before dispatch");
|
|
139
|
+
exact(database, selected, snapshot.account);
|
|
140
|
+
if (action.kind === "reaction" && !database.query("SELECT msg_id FROM messages WHERE chat_jid=? AND msg_id=? AND revoked=0 AND deleted_for_me=0").get(selected.conversationJid, action.messageId)) throw new Error("WhatsApp reaction target is unavailable");
|
|
141
|
+
return { ...request, account: snapshot.account, generation: snapshot.generation };
|
|
142
|
+
}, signal);
|
|
143
|
+
signal?.throwIfAborted(); dispatched = true;
|
|
144
|
+
const result = await runtime.request(request, signal, async () => { if (sha(await options.authorize(action.kind, signal)) !== sha(admission)) throw new Error("WhatsApp permission changed before writing the action"); });
|
|
145
|
+
if (result.state === "accepted") return { state: "accepted", messageId: result.messageId || null, providerReceiptId: result.requestId, delivery: "unknown" };
|
|
146
|
+
return result.state === "not-started" ? { state: "not-started", reason: "The exact WhatsApp request was not admitted." } : { state: "indeterminate", reason: "The WhatsApp outcome is uncertain and cannot be retried." };
|
|
147
|
+
} finally { await asset?.close(); }
|
|
148
|
+
});
|
|
149
|
+
} catch { return { state: dispatched ? "indeterminate" : "not-started", reason: dispatched ? "The WhatsApp receipt or cleanup could not be verified; do not retry." : "The selected WhatsApp account, permission, target, or action was unavailable." }; }
|
|
150
|
+
},
|
|
151
|
+
async close() { closed = true; lifetime.abort(); await inFlight?.catch(() => undefined); await runtime.close(); },
|
|
152
|
+
};
|
|
153
|
+
}
|