@hraness/ghostget 0.17.6 → 0.18.2
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 +301 -0
- package/README.md +16 -9
- 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-en5hycxp.js +175 -0
- package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
- 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 +147 -0
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +66 -7
- package/skills/ghostget/SKILL.md +3 -1
- package/skills/ghostget/references/control-panel.md +43 -0
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +222 -12
- 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/auth.ts +35 -1
- package/src/beeper-client-types.ts +1 -1
- package/src/cli.ts +18 -0
- package/src/confirmed-write-platform.ts +16 -1
- package/src/control/account-revision.ts +16 -0
- package/src/control/activity.ts +104 -0
- package/src/control/approval-broker.ts +59 -0
- package/src/control/approval-client.ts +49 -0
- package/src/control/bundled-interfaces.ts +20 -0
- package/src/control/cli.ts +20 -0
- package/src/control/connections.ts +87 -0
- package/src/control/credential-helper.ts +152 -0
- package/src/control/helper.ts +79 -0
- package/src/control/interface-cli.ts +22 -0
- package/src/control/interface-json.ts +94 -0
- package/src/control/interface-schema.ts +120 -0
- package/src/control/interfaces.ts +438 -0
- package/src/control/protocol.ts +182 -0
- package/src/control/service.ts +104 -0
- package/src/control/validation.ts +103 -0
- package/src/control/vault.ts +105 -0
- package/src/control/web-gateway.ts +62 -0
- package/src/control/web-policy.ts +56 -0
- package/src/ghostget.ts +18 -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/messaging-runtime.ts +3 -0
- package/src/oauth-google.ts +11 -5
- package/src/omni-runtime.ts +18 -3
- package/src/operation-permission-store.ts +92 -0
- package/src/operation-permission.ts +308 -0
- package/src/pinned-https.ts +5 -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-http.ts +11 -3
- package/src/provider-plugin-contract-identity.ts +2 -2
- package/src/provider-plugin-import-analysis.ts +52 -0
- package/src/provider-plugin-module-analysis.ts +21 -1
- package/src/provider-plugin-registry.ts +4 -8
- package/src/provider-plugin.ts +18 -8
- 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 +34 -5
- package/src/providers/linkedin-web-contact.ts +1223 -35
- 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/read-client.ts +12 -2
- package/src/runtime.ts +81 -9
- package/src/state-helper.ts +2 -0
- package/src/storage.ts +71 -1
- package/src/usage.ts +9 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
3
|
+
import type { GhostgetAuth } from "./auth";
|
|
4
|
+
import { MessagingAutomationHost } from "./messaging-automation";
|
|
5
|
+
import type { AutomationActionKind, AutomationProviderId, AutomationProviderStatus, MessagingAutomationProvider } from "./messaging-automation-types";
|
|
6
|
+
import { AUTOMATION_ACTION_KINDS } from "./messaging-automation-validation";
|
|
7
|
+
import { automationPermissionOperation, loadImsgAutomationRuntime, loadWhatsAppAutomationRuntime } from "./messaging-automation-descriptors";
|
|
8
|
+
import { describeOperationPermission, type OperationPermissionDescription } from "./operation-permission";
|
|
9
|
+
import { loadProviderPluginExtensionRuntime } from "./provider-plugin";
|
|
10
|
+
import type { ProviderPluginRegistry } from "./provider-plugin-registry";
|
|
11
|
+
import { acquireWebSessionCleanupAdmission, type WebSessionCleanupAdmissionController } from "./web-session-cleanup-admission";
|
|
12
|
+
import type { ProviderPluginCleanupBarrierRegistrar } from "./provider-plugin-cleanup-execution";
|
|
13
|
+
import { AutomationHostRecoveryRequired, type AutomationHostAccount, type AutomationHostSession } from "./messaging-automation-server";
|
|
14
|
+
|
|
15
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
16
|
+
type Concrete = MessagingAutomationProvider & { start?(signal?: AbortSignal): Promise<void> };
|
|
17
|
+
type Admission = Readonly<{ auth: GhostgetAuth; accountIdentity: string; implementationIdentity: string }>;
|
|
18
|
+
export type MessagingAutomationSessionOptions = Readonly<{
|
|
19
|
+
providers: readonly AutomationHostAccount[];
|
|
20
|
+
environment: Environment;
|
|
21
|
+
registry: ProviderPluginRegistry;
|
|
22
|
+
resolveAsset(assetId: string, signal?: AbortSignal): Promise<Readonly<{ bytes: Uint8Array; sha256: string }>>;
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
/** Reads account metadata only when an explicit owner operation calls authorize.
|
|
26
|
+
* Creation never starts a network connection or reads a conversation. */
|
|
27
|
+
export async function createMessagingAutomationSession(options: MessagingAutomationSessionOptions): Promise<AutomationHostSession> {
|
|
28
|
+
const providers: MessagingAutomationProvider[] = [];
|
|
29
|
+
const starts = new Map<AutomationProviderId, (signal?: AbortSignal) => Promise<AutomationProviderStatus>>();
|
|
30
|
+
try {
|
|
31
|
+
for (const selected of options.providers) {
|
|
32
|
+
const adapterId = selected.provider === "imessage" ? "imessage-direct" : "whatsapp-web";
|
|
33
|
+
let custody: WebSessionCleanupAdmissionController | undefined;
|
|
34
|
+
let custodyIdentity: string | undefined;
|
|
35
|
+
let poisoned = false, persistent = false, closed = false, started = false;
|
|
36
|
+
let watching: ReturnType<typeof setInterval> | undefined;
|
|
37
|
+
let closing: Promise<void> | undefined;
|
|
38
|
+
const describe = (operation: string, signal?: AbortSignal) => describeOperationPermission(adapterId, automationPermissionOperation(operation), selected.authId, { registry: options.registry, environment: options.environment, ...(signal ? { signal } : {}) });
|
|
39
|
+
const identity = (description: OperationPermissionDescription) => sha256(canonicalJson({
|
|
40
|
+
protocol: "ghostget.messaging-automation/1", provider: selected.provider,
|
|
41
|
+
pluginId: description.coordinate.pluginId, manifestHash: description.coordinate.manifestHash,
|
|
42
|
+
closureHash: description.coordinate.closureHash, portableHash: description.coordinate.portableHash,
|
|
43
|
+
}));
|
|
44
|
+
const authorize = async (operation: string, signal?: AbortSignal): Promise<Admission> => {
|
|
45
|
+
signal?.throwIfAborted();
|
|
46
|
+
if (poisoned) throw new AutomationHostRecoveryRequired("Host custody unavailable");
|
|
47
|
+
if (closed) throw new Error("Host closed");
|
|
48
|
+
const description = describe(operation, signal);
|
|
49
|
+
if (description.decision !== "allow") throw new Error("Explicit managed operation allow required");
|
|
50
|
+
const auth = description.auth as GhostgetAuth;
|
|
51
|
+
if (auth.kind !== "linked-device-store" || auth.provider !== selected.provider || auth.id !== selected.authId) throw new Error("Exact linked-device account required");
|
|
52
|
+
const implementationIdentity = identity(description);
|
|
53
|
+
const coordinate = sha256(canonicalJson({ auth, incarnation: description.coordinate.authIncarnation, implementationIdentity }));
|
|
54
|
+
if (custody && coordinate !== custodyIdentity) throw new Error("Account or implementation changed during host custody");
|
|
55
|
+
if (!custody) {
|
|
56
|
+
const plugin = description.resolution.plugin;
|
|
57
|
+
custody = acquireWebSessionCleanupAdmission({
|
|
58
|
+
runId: randomUUID(), pluginId: plugin.id, pluginVersion: plugin.version,
|
|
59
|
+
pluginImplementationHash: options.registry.implementationHash(description.resolution.binding).toString("hex"),
|
|
60
|
+
adapterId, adapterHash: description.coordinate.manifestHash, surfaceId: selected.provider,
|
|
61
|
+
authId: selected.authId, authHash: sha256(canonicalJson(auth)),
|
|
62
|
+
transport: selected.provider === "imessage" ? "local-cli" : "web-session-api",
|
|
63
|
+
executionIdentityHash: implementationIdentity,
|
|
64
|
+
}, options.environment);
|
|
65
|
+
custodyIdentity = coordinate;
|
|
66
|
+
}
|
|
67
|
+
return { auth, accountIdentity: description.coordinate.authIncarnation, implementationIdentity };
|
|
68
|
+
};
|
|
69
|
+
const registerCleanupBarrier: ProviderPluginCleanupBarrierRegistrar = barrier => {
|
|
70
|
+
if (!custody || poisoned || closed) throw new AutomationHostRecoveryRequired("Provider cleanup has no durable admission");
|
|
71
|
+
return custody.registerCleanupBarrier(barrier);
|
|
72
|
+
};
|
|
73
|
+
let finishing: Promise<void> | undefined;
|
|
74
|
+
const finishCustody = (): Promise<void> => {
|
|
75
|
+
if (finishing) return finishing;
|
|
76
|
+
finishing = (async () => {
|
|
77
|
+
if (!custody) return;
|
|
78
|
+
const owned = custody; owned.closeRegistration();
|
|
79
|
+
const settled = await Promise.allSettled(owned.barriers);
|
|
80
|
+
if (settled.some(item => item.status === "rejected")) {
|
|
81
|
+
poisoned = true; owned.cleanupUnsafe(); throw new AutomationHostRecoveryRequired("Provider cleanup remains uncertain");
|
|
82
|
+
}
|
|
83
|
+
owned.cleanupComplete(); owned.release(); custody = undefined; custodyIdentity = undefined;
|
|
84
|
+
})().finally(() => { finishing = undefined; });
|
|
85
|
+
return finishing;
|
|
86
|
+
};
|
|
87
|
+
const execution = { environment: options.environment, registerCleanupBarrier };
|
|
88
|
+
const binding = options.registry.requireOperationDefinition(selected.provider === "imessage" ? "local-cli" : "linked-device", selected.provider, "messaging.automation.read", 1).binding;
|
|
89
|
+
const concrete: Concrete = await loadProviderPluginExtensionRuntime(binding.loadRuntime, async () => selected.provider === "imessage"
|
|
90
|
+
? (await loadImsgAutomationRuntime()).createImsgAutomationProvider({ authorize, execution, resolveAsset: options.resolveAsset })
|
|
91
|
+
: (await loadWhatsAppAutomationRuntime()).createWhatsAppAutomationProvider({ authorize, execution, resolveAsset: options.resolveAsset }));
|
|
92
|
+
const call = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
93
|
+
if (poisoned) throw new AutomationHostRecoveryRequired("Host is fenced");
|
|
94
|
+
if (closed) throw new Error("Host closed");
|
|
95
|
+
try { return await work(); } finally { if (!persistent) await finishCustody(); }
|
|
96
|
+
};
|
|
97
|
+
const status = async (signal?: AbortSignal) => call(async () => {
|
|
98
|
+
const status = await concrete.inspect(signal);
|
|
99
|
+
const actions = Object.fromEntries(AUTOMATION_ACTION_KINDS.map((kind: AutomationActionKind) => {
|
|
100
|
+
let allowed = false;
|
|
101
|
+
try { const admission = describe(kind, signal); allowed = admission.decision === "allow" && identity(admission) === status.identity.implementationIdentity && admission.coordinate.authIncarnation === status.identity.accountIdentity; } catch { /* unsupported operation has no authority */ }
|
|
102
|
+
return [kind, allowed || !status.actions[kind].available ? status.actions[kind] : { available: false, reason: "Enable this exact account operation in Ghostget permissions before granting a contact access." }];
|
|
103
|
+
})) as AutomationProviderStatus["actions"];
|
|
104
|
+
return { ...status, actions };
|
|
105
|
+
});
|
|
106
|
+
const wrapped: MessagingAutomationProvider = {
|
|
107
|
+
provider: selected.provider, inspect: status,
|
|
108
|
+
conversations: (input, signal) => call(() => concrete.conversations(input, signal)),
|
|
109
|
+
resolve: (input, signal) => call(() => concrete.resolve(input, signal)),
|
|
110
|
+
history: (input, signal) => call(() => concrete.history(input, signal)),
|
|
111
|
+
events: (input, signal) => call(() => concrete.events(input, signal)),
|
|
112
|
+
send: (input, signal) => call(() => concrete.send(input, signal)),
|
|
113
|
+
close: () => {
|
|
114
|
+
if (closing) return closing;
|
|
115
|
+
if (watching) { clearInterval(watching); watching = undefined; }
|
|
116
|
+
closing = (async () => {
|
|
117
|
+
try { await concrete.close(); await finishCustody(); closed = true; }
|
|
118
|
+
catch { poisoned = true; custody?.closeRegistration(); custody?.cleanupUnsafe(); throw new AutomationHostRecoveryRequired("Provider cleanup requires recovery"); }
|
|
119
|
+
})();
|
|
120
|
+
return closing;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
providers.push(wrapped);
|
|
124
|
+
starts.set(selected.provider, async signal => {
|
|
125
|
+
if (!concrete.start || selected.provider !== "whatsapp" || closed || poisoned) throw new Error("This provider has no start operation");
|
|
126
|
+
if (started) return status(signal);
|
|
127
|
+
persistent = true;
|
|
128
|
+
try {
|
|
129
|
+
// The sync ceiling is independent of send/read allows. Watch its
|
|
130
|
+
// exact decision while the linked-device process remains alive.
|
|
131
|
+
const ceiling = describe("start", signal);
|
|
132
|
+
if (ceiling.decision !== "allow") throw new Error("Explicit sync permission required");
|
|
133
|
+
await concrete.start(signal); started = true;
|
|
134
|
+
const check = () => {
|
|
135
|
+
let allowed = false;
|
|
136
|
+
try { const current = describe("start"); allowed = current.decision === "allow" && current.digest === ceiling.digest && current.coordinate.authIncarnation === ceiling.coordinate.authIncarnation; } catch { /* changed/missing auth or policy revokes active sync */ }
|
|
137
|
+
if (!allowed) void wrapped.close().catch(() => undefined);
|
|
138
|
+
};
|
|
139
|
+
watching = setInterval(check, 1_000); watching.unref(); check();
|
|
140
|
+
return await status(signal);
|
|
141
|
+
}
|
|
142
|
+
catch (error) { try { await wrapped.close(); } catch { throw new AutomationHostRecoveryRequired("Provider start cleanup requires recovery"); } throw error; }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const host = new MessagingAutomationHost(providers, options.environment);
|
|
146
|
+
return { host, start: (provider, signal) => { const start = starts.get(provider); if (!start) throw new Error("Provider not configured"); return start(signal); } };
|
|
147
|
+
} catch (error) { await Promise.allSettled(providers.map(provider => provider.close())); throw error; }
|
|
148
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import type { Readable, Writable } from "node:stream";
|
|
4
|
+
import { canonicalJson } from "./canonical-json";
|
|
5
|
+
import { MessagingAutomationHost } from "./messaging-automation";
|
|
6
|
+
import { MESSAGING_AUTOMATION_PROTOCOL as protocol, type AutomationGrantRequest, type AutomationProviderId, type AutomationProviderStatus } from "./messaging-automation-types";
|
|
7
|
+
import { automationArray, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction } from "./messaging-automation-validation";
|
|
8
|
+
import { createMessagingAutomationSession } from "./messaging-automation-factory";
|
|
9
|
+
import type { ProviderPluginRegistry } from "./provider-plugin-registry";
|
|
10
|
+
|
|
11
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
12
|
+
export type AutomationHostAccount = Readonly<{ provider: AutomationProviderId; authId: string }>;
|
|
13
|
+
export type AutomationHostSession = Readonly<{ host: MessagingAutomationHost; start(provider: AutomationProviderId, signal?: AbortSignal): Promise<AutomationProviderStatus> }>;
|
|
14
|
+
type Asset = { bytes: Uint8Array; sha256: string; expires: number; plan: string | null };
|
|
15
|
+
const MAX_ASSET = 16 * 1024 * 1024;
|
|
16
|
+
const MAX_FRAME = 24 * 1024 * 1024;
|
|
17
|
+
const MAX_RESPONSE = 32 * 1024 * 1024;
|
|
18
|
+
const provider = (value: unknown): AutomationProviderId => { if (value !== "imessage" && value !== "whatsapp") throw new Error("Invalid provider"); return value; };
|
|
19
|
+
export class AutomationHostRecoveryRequired extends Error {}
|
|
20
|
+
|
|
21
|
+
/** One trusted owner connection. No agent receives this port. */
|
|
22
|
+
export class MessagingAutomationRpcServer {
|
|
23
|
+
private session: AutomationHostSession | undefined;
|
|
24
|
+
private initialized = false;
|
|
25
|
+
private initializing: Promise<AutomationHostSession> | undefined;
|
|
26
|
+
private closed = false;
|
|
27
|
+
private normalBusy = false;
|
|
28
|
+
private priorityBusy = 0;
|
|
29
|
+
private readonly requests = new Set<string>();
|
|
30
|
+
private readonly assets = new Map<string, Asset>();
|
|
31
|
+
private executingPlan: string | null = null;
|
|
32
|
+
private readonly abort = new AbortController();
|
|
33
|
+
private closing: Promise<void> | undefined;
|
|
34
|
+
constructor(private readonly options: Readonly<{
|
|
35
|
+
environment: Environment; registry: ProviderPluginRegistry;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
createSession?: typeof createMessagingAutomationSession;
|
|
38
|
+
}>) {}
|
|
39
|
+
private now() { return this.options.now?.() ?? Date.now(); }
|
|
40
|
+
private sweep() { for (const [id, asset] of this.assets) if (asset.expires <= this.now() && asset.plan !== this.executingPlan) this.assets.delete(id); }
|
|
41
|
+
private host() { if (!this.session || this.closed) throw new Error("Host not ready"); return this.session.host; }
|
|
42
|
+
private async dispatch(method: string, raw: unknown): Promise<unknown> {
|
|
43
|
+
this.sweep();
|
|
44
|
+
if (method === "initialize") {
|
|
45
|
+
if (this.initialized || this.closed) throw new Error("Already initialized");
|
|
46
|
+
const r = automationRecord(raw, ["providers"]);
|
|
47
|
+
const accounts = automationArray(r.providers, 2).map(value => {
|
|
48
|
+
const item = automationRecord(value, ["provider", "authId"]); const authId = automationText(item.authId, 48);
|
|
49
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/u.test(authId)) throw new Error("Invalid account identifier");
|
|
50
|
+
return { provider: provider(item.provider), authId };
|
|
51
|
+
});
|
|
52
|
+
if (accounts.length === 0 || new Set(accounts.map(item => item.provider)).size !== accounts.length) throw new Error("Distinct configured providers required");
|
|
53
|
+
this.initialized = true;
|
|
54
|
+
this.initializing = (this.options.createSession ?? createMessagingAutomationSession)({
|
|
55
|
+
providers: accounts, environment: this.options.environment, registry: this.options.registry,
|
|
56
|
+
resolveAsset: async id => {
|
|
57
|
+
this.sweep(); const asset = this.assets.get(id);
|
|
58
|
+
if (!asset || !this.executingPlan || asset.plan !== this.executingPlan || asset.expires <= this.now()) throw new Error("Asset is unavailable for this plan");
|
|
59
|
+
return { bytes: new Uint8Array(asset.bytes), sha256: asset.sha256 };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
this.session = await this.initializing;
|
|
63
|
+
if (this.closed) throw new Error("Host closed during initialization");
|
|
64
|
+
return { initialized: true };
|
|
65
|
+
}
|
|
66
|
+
if (method === "close") { automationRecord(raw, []); await this.close(); return { closed: true }; }
|
|
67
|
+
const host = this.host();
|
|
68
|
+
if (method === "status" || method === "start") {
|
|
69
|
+
const r = automationRecord(raw, ["provider"]); const selected = provider(r.provider);
|
|
70
|
+
return method === "status" ? host.providerStatus(selected, this.abort.signal) : this.session!.start(selected, this.abort.signal);
|
|
71
|
+
}
|
|
72
|
+
if (method === "conversations") { const r = automationRecord(raw, ["provider", "limit"]); return host.conversations({ provider: provider(r.provider), limit: automationInteger(r.limit, 1, 200) }, this.abort.signal); }
|
|
73
|
+
if (method === "enroll") { const r = automationRecord(raw, ["provider", "coordinate"]); return host.enroll({ provider: provider(r.provider), coordinate: r.coordinate }, this.abort.signal); }
|
|
74
|
+
if (method === "enrollments") { automationRecord(raw, []); return host.enrollments(); }
|
|
75
|
+
if (method === "grant") {
|
|
76
|
+
const r = automationRecord(raw, ["intentId", "enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]);
|
|
77
|
+
const { intentId, ...request } = r; return host.grant(request as AutomationGrantRequest, automationId(intentId));
|
|
78
|
+
}
|
|
79
|
+
if (method === "grant.by-intent") { const r = automationRecord(raw, ["intentId"]); return { grant: host.grantByIntent(automationId(r.intentId)) }; }
|
|
80
|
+
if (method === "grant.get") { const r = automationRecord(raw, ["grantId"]); return host.grantStatus(automationId(r.grantId)); }
|
|
81
|
+
if (method === "revoke") { const r = automationRecord(raw, ["grantId"]); host.revoke(automationId(r.grantId)); return { revoked: true }; }
|
|
82
|
+
if (method === "cancel") { const r = automationRecord(raw, ["planId"]); return { cancelled: host.cancel(automationId(r.planId)) }; }
|
|
83
|
+
if (method === "poll") { const r = automationRecord(raw, ["enrollmentId"]); return host.poll(automationId(r.enrollmentId), this.abort.signal); }
|
|
84
|
+
if (method === "history") { const r = automationRecord(raw, ["enrollmentId", "limit"]); return host.history({ enrollmentId: automationId(r.enrollmentId), limit: automationInteger(r.limit, 1, 200) }); }
|
|
85
|
+
if (method === "events") {
|
|
86
|
+
const r = automationRecord(raw, ["enrollmentIds", "cursor", "limit"]);
|
|
87
|
+
return host.events({ enrollmentIds: automationArray(r.enrollmentIds, 50).map(automationId), cursor: r.cursor === null ? null : automationText(r.cursor, 16_384), limit: automationInteger(r.limit, 1, 500) });
|
|
88
|
+
}
|
|
89
|
+
if (method === "asset") {
|
|
90
|
+
const r = automationRecord(raw, ["bytesBase64", "sha256"]); const digest = automationDigest(r.sha256);
|
|
91
|
+
if (typeof r.bytesBase64 !== "string" || r.bytesBase64.length > Math.ceil(MAX_ASSET / 3) * 4) throw new Error("Invalid asset length");
|
|
92
|
+
const bytes = Buffer.from(r.bytesBase64, "base64");
|
|
93
|
+
if (!bytes.length || bytes.length > MAX_ASSET || bytes.toString("base64") !== r.bytesBase64 || createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error("Invalid asset");
|
|
94
|
+
if (this.assets.size >= 32 || [...this.assets.values()].reduce((sum, asset) => sum + asset.bytes.length, bytes.length) > 64 * 1024 * 1024) throw new Error("Asset capacity exceeded");
|
|
95
|
+
const assetId = `asset:${randomUUID()}`, expires = this.now() + 300_000;
|
|
96
|
+
this.assets.set(assetId, { bytes, sha256: digest, expires, plan: null });
|
|
97
|
+
return { assetId, bytes: bytes.length, sha256: digest, expiresAt: new Date(expires).toISOString() };
|
|
98
|
+
}
|
|
99
|
+
if (method === "prepare") {
|
|
100
|
+
const r = automationRecord(raw, ["enrollmentId", "expectedRevision", "intentId", "actions"]);
|
|
101
|
+
const actions = automationArray(r.actions, 8).map(parseAutomationAction);
|
|
102
|
+
const ids = new Set(actions.flatMap(action => action.kind === "attachment" || action.kind === "sticker" ? [action.assetId] : []));
|
|
103
|
+
for (const id of ids) { const asset = this.assets.get(id); if (!asset || asset.plan !== null) throw new Error("Missing or already-bound asset"); }
|
|
104
|
+
const plan = host.prepare({ enrollmentId: automationId(r.enrollmentId), expectedRevision: automationInteger(r.expectedRevision, 0, Number.MAX_SAFE_INTEGER), intentId: automationId(r.intentId), actions });
|
|
105
|
+
for (const id of ids) { const asset = this.assets.get(id)!; asset.plan = plan.id; asset.expires = Date.parse(plan.expiresAt); }
|
|
106
|
+
return plan;
|
|
107
|
+
}
|
|
108
|
+
if (method === "submit") {
|
|
109
|
+
const r = automationRecord(raw, ["planId", "grantId"]); const planId = automationId(r.planId); this.executingPlan = planId;
|
|
110
|
+
try { return await host.submit({ planId, grantId: automationId(r.grantId) }, this.abort.signal); }
|
|
111
|
+
finally { this.executingPlan = null; for (const [id, asset] of this.assets) if (asset.plan === planId) this.assets.delete(id); }
|
|
112
|
+
}
|
|
113
|
+
if (method === "run") { const r = automationRecord(raw, ["runId"]); return host.run(automationId(r.runId)); }
|
|
114
|
+
throw new Error("Unknown method");
|
|
115
|
+
}
|
|
116
|
+
async handle(value: unknown): Promise<unknown> {
|
|
117
|
+
let id = "invalid", priority = false, admitted = false;
|
|
118
|
+
try {
|
|
119
|
+
const r = automationRecord(value, ["protocol", "id", "method", "params"]);
|
|
120
|
+
id = automationText(r.id, 64); if (!/^[A-Za-z0-9._:-]+$/u.test(id) || r.protocol !== protocol) throw new Error("Invalid envelope");
|
|
121
|
+
const method = automationText(r.method, 32); priority = ["cancel", "revoke", "close"].includes(method);
|
|
122
|
+
if (this.requests.has(id) || (priority ? this.priorityBusy >= 8 : this.normalBusy)) return { protocol, id, ok: false, error: { code: "not-ready", message: "The owner host is busy or this request is already active." } };
|
|
123
|
+
this.requests.add(id); admitted = true;
|
|
124
|
+
if (priority) this.priorityBusy++; else this.normalBusy = true;
|
|
125
|
+
const result = await this.dispatch(method, r.params);
|
|
126
|
+
return { protocol, id, ok: true, result };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { protocol, id, ok: false, error: { code: error instanceof AutomationHostRecoveryRequired ? "recovery-required" : this.closed ? "not-ready" : "unavailable", message: error instanceof AutomationHostRecoveryRequired ? "Provider cleanup requires explicit host recovery; this instance cannot continue." : "The requested operation is unavailable. Review account permissions, provider setup, scope and current state." } };
|
|
129
|
+
} finally { if (admitted) { this.requests.delete(id); if (priority) this.priorityBusy--; else this.normalBusy = false; } }
|
|
130
|
+
}
|
|
131
|
+
close(): Promise<void> {
|
|
132
|
+
this.closed = true; this.abort.abort(); this.assets.clear();
|
|
133
|
+
return this.closing ??= (async () => {
|
|
134
|
+
const session = this.session ?? await this.initializing?.catch(() => undefined);
|
|
135
|
+
if (session) { try { await session.host.close(); } catch { throw new AutomationHostRecoveryRequired("Host cleanup requires recovery"); } }
|
|
136
|
+
})();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function serveMessagingAutomationStdio(options: Readonly<{
|
|
141
|
+
input: Readable; output: Writable; environment: Environment; registry: ProviderPluginRegistry; signal?: AbortSignal;
|
|
142
|
+
}>): Promise<void> {
|
|
143
|
+
const server = new MessagingAutomationRpcServer(options);
|
|
144
|
+
const pending = new Set<Promise<void>>(); let writing = Promise.resolve();
|
|
145
|
+
const write = (value: unknown) => writing = writing.then(async () => {
|
|
146
|
+
const frame = canonicalJson(value) + "\n";
|
|
147
|
+
if (Buffer.byteLength(frame) > MAX_RESPONSE) throw new Error("Owner response exceeds bound");
|
|
148
|
+
if (!options.output.write(frame)) {
|
|
149
|
+
const abort = new AbortController();
|
|
150
|
+
const timer = setTimeout(() => abort.abort(), 30_000);
|
|
151
|
+
try { await once(options.output, "drain", { signal: abort.signal }); }
|
|
152
|
+
finally { clearTimeout(timer); }
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const stop = () => { options.input.destroy(); void server.close().catch(() => undefined); };
|
|
156
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
157
|
+
let buffer = Buffer.alloc(0); let failure: unknown;
|
|
158
|
+
try {
|
|
159
|
+
options.signal?.throwIfAborted();
|
|
160
|
+
for await (const chunk of options.input) {
|
|
161
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
162
|
+
if (buffer.length + bytes.length > MAX_FRAME * 2) throw new Error("Owner frame exceeds bound");
|
|
163
|
+
buffer = Buffer.concat([buffer, bytes]);
|
|
164
|
+
for (;;) {
|
|
165
|
+
const newline = buffer.indexOf(10); if (newline < 0) break;
|
|
166
|
+
if (newline > MAX_FRAME || pending.size >= 10) throw new Error("Owner request capacity exceeded");
|
|
167
|
+
const line = buffer.subarray(0, newline); buffer = buffer.subarray(newline + 1);
|
|
168
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line));
|
|
169
|
+
let task: Promise<void>;
|
|
170
|
+
task = server.handle(value).then(write).catch(error => { failure ??= error; stop(); }).finally(() => pending.delete(task));
|
|
171
|
+
pending.add(task);
|
|
172
|
+
}
|
|
173
|
+
if (buffer.length > MAX_FRAME) throw new Error("Owner frame exceeds bound");
|
|
174
|
+
}
|
|
175
|
+
if (buffer.length) throw new Error("Owner request was truncated");
|
|
176
|
+
} catch (error) { failure ??= error; }
|
|
177
|
+
finally {
|
|
178
|
+
options.signal?.removeEventListener("abort", stop);
|
|
179
|
+
try { await server.close(); } catch (error) { failure ??= error; }
|
|
180
|
+
await Promise.allSettled(pending); await writing;
|
|
181
|
+
}
|
|
182
|
+
if (failure) throw failure;
|
|
183
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Closed host-side messaging contract. Provider credentials and local paths never cross it. */
|
|
2
|
+
export const MESSAGING_AUTOMATION_PROTOCOL = "ghostget.messaging-automation/1" as const;
|
|
3
|
+
|
|
4
|
+
export type AutomationProviderId = "imessage" | "whatsapp";
|
|
5
|
+
export type AutomationActionKind = AutomationAction["kind"];
|
|
6
|
+
export type AutomationCoordinate =
|
|
7
|
+
| Readonly<{ provider: "imessage"; chatGuid: string; service: "iMessage"; observedChatRowId: number }>
|
|
8
|
+
| Readonly<{ provider: "whatsapp"; conversationJid: string }>;
|
|
9
|
+
export type AutomationIdentity = Readonly<{
|
|
10
|
+
provider: AutomationProviderId;
|
|
11
|
+
authId: string;
|
|
12
|
+
/** Ghostget's public lifecycle authIdentity; detects replacement including A → B → A. */
|
|
13
|
+
accountIdentity: string;
|
|
14
|
+
accountSubject: string;
|
|
15
|
+
/** Exact reviewed provider implementation and contract closure digest. */
|
|
16
|
+
implementationIdentity: string;
|
|
17
|
+
/** Exact provider database/session generation; changes invalidate cursors and enrollment. */
|
|
18
|
+
sourceGeneration: string;
|
|
19
|
+
}>;
|
|
20
|
+
export type AutomationCapability = Readonly<{ available: boolean; reason: string | null }>;
|
|
21
|
+
export type AutomationProviderStatus = Readonly<{
|
|
22
|
+
identity: AutomationIdentity;
|
|
23
|
+
connected: boolean;
|
|
24
|
+
events: AutomationCapability;
|
|
25
|
+
actions: Readonly<Record<AutomationActionKind, AutomationCapability>>;
|
|
26
|
+
}>;
|
|
27
|
+
export type AutomationConversation = Readonly<{
|
|
28
|
+
coordinate: AutomationCoordinate;
|
|
29
|
+
title: string | null;
|
|
30
|
+
kind: "single" | "group" | "unknown";
|
|
31
|
+
/** Exact current participant identities, never inferred from a display name. */
|
|
32
|
+
participants: readonly string[];
|
|
33
|
+
}>;
|
|
34
|
+
export type AutomationMessage = Readonly<{
|
|
35
|
+
id: string;
|
|
36
|
+
coordinate: AutomationCoordinate;
|
|
37
|
+
direction: "incoming" | "outgoing" | "unknown";
|
|
38
|
+
occurredAt: string;
|
|
39
|
+
text: string | null;
|
|
40
|
+
kind: "message" | "reaction" | "edit" | "delete";
|
|
41
|
+
relatedMessageId: string | null;
|
|
42
|
+
/** Metadata only; provider keys and paths stay inside Ghostget. */
|
|
43
|
+
attachments: readonly Readonly<{ name: string | null; mimeType: string | null; sizeBytes: number | null }>[];
|
|
44
|
+
}>;
|
|
45
|
+
export type AutomationAction =
|
|
46
|
+
| Readonly<{ kind: "text"; text: string }>
|
|
47
|
+
| Readonly<{ kind: "attachment"; assetId: string; name: string; mimeType: string }>
|
|
48
|
+
| Readonly<{ kind: "reaction"; messageId: string; emoji: string; remove: boolean }>
|
|
49
|
+
| Readonly<{ kind: "sticker"; assetId: string; messageId: string | null }>
|
|
50
|
+
| Readonly<{ kind: "link"; url: string }>
|
|
51
|
+
| Readonly<{ kind: "poll"; question: string; options: readonly string[]; maximumSelections: number | null }>
|
|
52
|
+
| Readonly<{ kind: "app-clip"; url: string }>
|
|
53
|
+
| Readonly<{ kind: "experience"; experienceId: string; parameters: Readonly<Record<string, string>> }>;
|
|
54
|
+
export type AutomationProviderPage = Readonly<{
|
|
55
|
+
identity: AutomationIdentity;
|
|
56
|
+
messages: readonly AutomationMessage[];
|
|
57
|
+
nextCursor: string;
|
|
58
|
+
caughtUp: boolean;
|
|
59
|
+
/** A known/possible lost interval is explicit and prevents automatic dispatch. */
|
|
60
|
+
gap: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
export type AutomationProviderSendResult =
|
|
63
|
+
| Readonly<{ state: "accepted"; messageId: string | null; providerReceiptId: string | null; delivery: "unknown" }>
|
|
64
|
+
| Readonly<{ state: "not-started"; reason: string }>
|
|
65
|
+
| Readonly<{ state: "indeterminate"; reason: string }>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Trusted Ghostget host port, never handed to an agent. Production implementations
|
|
69
|
+
* check existing operation permissions and exact identity before each operation.
|
|
70
|
+
* One send means one dispatch; implementations must never retry after uncertainty.
|
|
71
|
+
*/
|
|
72
|
+
export interface MessagingAutomationProvider {
|
|
73
|
+
readonly provider: AutomationProviderId;
|
|
74
|
+
inspect(signal?: AbortSignal): Promise<AutomationProviderStatus>;
|
|
75
|
+
conversations(input: Readonly<{ limit: number }>, signal?: AbortSignal): Promise<Readonly<{
|
|
76
|
+
identity: AutomationIdentity; conversations: readonly AutomationConversation[]; complete: boolean;
|
|
77
|
+
}>>;
|
|
78
|
+
resolve(coordinate: AutomationCoordinate, signal?: AbortSignal): Promise<Readonly<{
|
|
79
|
+
identity: AutomationIdentity; conversation: AutomationConversation;
|
|
80
|
+
}>>;
|
|
81
|
+
history(input: Readonly<{ coordinate: AutomationCoordinate; limit: number }>, signal?: AbortSignal): Promise<AutomationProviderPage>;
|
|
82
|
+
events(input: Readonly<{ coordinates: readonly AutomationCoordinate[]; cursor: string | null; limit: number }>, signal?: AbortSignal): Promise<AutomationProviderPage>;
|
|
83
|
+
send(input: Readonly<{
|
|
84
|
+
identity: AutomationIdentity;
|
|
85
|
+
coordinate: AutomationCoordinate;
|
|
86
|
+
action: AutomationAction;
|
|
87
|
+
/** Host's durable action claim, useful for provider idempotency only where proven. */
|
|
88
|
+
intentId: string;
|
|
89
|
+
}>, signal?: AbortSignal): Promise<AutomationProviderSendResult>;
|
|
90
|
+
/** Waits for owned provider children to terminate; uncertainty must reject. */
|
|
91
|
+
close(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type AutomationEnrollment = Readonly<{
|
|
95
|
+
id: string; identity: AutomationIdentity; conversation: AutomationConversation;
|
|
96
|
+
bindingDigest: string; revision: number; ready: boolean; reason: string | null;
|
|
97
|
+
}>;
|
|
98
|
+
export type AutomationGrantRequest = Readonly<{
|
|
99
|
+
enrollmentId: string; expectedBindingDigest: string;
|
|
100
|
+
actions: readonly AutomationActionKind[];
|
|
101
|
+
expiresAt: string; maximumActions: number; minimumIntervalMs: number;
|
|
102
|
+
}>;
|
|
103
|
+
export type AutomationGrant = AutomationGrantRequest & Readonly<{ id: string; revoked: boolean; consumedActions: number }>;
|
|
104
|
+
export type AutomationPlanRequest = Readonly<{
|
|
105
|
+
enrollmentId: string; expectedRevision: number; intentId: string; actions: readonly AutomationAction[];
|
|
106
|
+
}>;
|
|
107
|
+
export type AutomationPlan = AutomationPlanRequest & Readonly<{ id: string; digest: string; bindingDigest: string; expiresAt: string }>;
|
|
108
|
+
export type AutomationRun = Readonly<{
|
|
109
|
+
id: string; planId: string; intentId: string; enrollmentId: string;
|
|
110
|
+
state: "started" | "accepted" | "failed" | "partial" | "indeterminate";
|
|
111
|
+
accepted: readonly Readonly<{ messageId: string | null; providerReceiptId: string | null }>[];
|
|
112
|
+
totalActions: number; reason: string | null; retryable: false;
|
|
113
|
+
}>;
|
|
114
|
+
export type AutomationEvent = Readonly<{ sequence: number; enrollmentId: string; revision: number; message: AutomationMessage }>;
|
|
115
|
+
|
|
116
|
+
/** Public trusted-host surface. No registry, database or provider implementation
|
|
117
|
+
* types are part of this contract. Creating a host requires Bun. */
|
|
118
|
+
export interface MessagingAutomationHostApi {
|
|
119
|
+
providerStatus(provider: AutomationProviderId, signal?: AbortSignal): Promise<AutomationProviderStatus>;
|
|
120
|
+
conversations(input: Readonly<{ provider: AutomationProviderId; limit: number }>, signal?: AbortSignal): Promise<Readonly<{
|
|
121
|
+
identity: AutomationIdentity; conversations: readonly AutomationConversation[]; complete: boolean;
|
|
122
|
+
}>>;
|
|
123
|
+
enroll(input: Readonly<{ provider: AutomationProviderId; coordinate: AutomationCoordinate }>, signal?: AbortSignal): Promise<AutomationEnrollment>;
|
|
124
|
+
enrollments(): readonly AutomationEnrollment[];
|
|
125
|
+
history(input: Readonly<{ enrollmentId: string; limit: number }>): Readonly<{ enrollment: AutomationEnrollment; messages: readonly AutomationMessage[] }>;
|
|
126
|
+
grant(request: AutomationGrantRequest, intentId?: string): AutomationGrant;
|
|
127
|
+
grantByIntent(intentId: string): AutomationGrant | null;
|
|
128
|
+
grantStatus(grantId: string): AutomationGrant;
|
|
129
|
+
revoke(grantId: string): void;
|
|
130
|
+
poll(enrollmentId: string, signal?: AbortSignal): Promise<AutomationEnrollment>;
|
|
131
|
+
events(input: Readonly<{ enrollmentIds: readonly string[]; cursor: string | null; limit: number }>): Readonly<{
|
|
132
|
+
events: readonly AutomationEvent[]; nextCursor: string; caughtUp: boolean;
|
|
133
|
+
}>;
|
|
134
|
+
prepare(request: AutomationPlanRequest): AutomationPlan;
|
|
135
|
+
submit(input: Readonly<{ planId: string; grantId: string }>, signal?: AbortSignal): Promise<AutomationRun>;
|
|
136
|
+
run(runId: string): AutomationRun;
|
|
137
|
+
cancel(planId: string): boolean;
|
|
138
|
+
/** Resolves only after all provider operations and owned children have joined. */
|
|
139
|
+
close(): Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type MessagingRuntimeInstallation = Readonly<{ version: string; sha256: string; alreadyPresent?: boolean }>;
|
|
143
|
+
|
|
144
|
+
/** Injected providers are trusted owner code and must enforce their permissions.
|
|
145
|
+
* Use the CLI stdio host for built-in account/registry-managed providers. */
|
|
146
|
+
export declare function createMessagingAutomationHost(
|
|
147
|
+
providers: readonly MessagingAutomationProvider[],
|
|
148
|
+
environment?: Readonly<Record<string, string | undefined>>,
|
|
149
|
+
): Promise<MessagingAutomationHostApi>;
|
|
150
|
+
|
|
151
|
+
/** Installs exact bundled bytes; never pairs, starts sync or grants authority. */
|
|
152
|
+
export declare function installBundledMessagingRuntime(
|
|
153
|
+
provider: AutomationProviderId,
|
|
154
|
+
environment?: Readonly<Record<string, string | undefined>>,
|
|
155
|
+
): Promise<MessagingRuntimeInstallation>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import type { AutomationAction, AutomationActionKind, AutomationCoordinate, AutomationIdentity, AutomationMessage } from "./messaging-automation-types";
|
|
3
|
+
|
|
4
|
+
export const AUTOMATION_ACTION_KINDS = Object.freeze(["text", "attachment", "reaction", "sticker", "link", "poll", "app-clip", "experience"] as const);
|
|
5
|
+
export function automationRecord(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
6
|
+
if (types.isProxy(value) || typeof value !== "object" || value === null || Array.isArray(value)
|
|
7
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error("Messaging automation value must be a plain object.");
|
|
8
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
9
|
+
if (Reflect.ownKeys(fields).length !== keys.length || keys.some(key => !Object.hasOwn(fields, key))
|
|
10
|
+
|| Reflect.ownKeys(fields).some(key => typeof key !== "string" || !keys.includes(key) || !fields[key]?.enumerable || !("value" in fields[key]!))) {
|
|
11
|
+
throw new Error("Messaging automation value has unsupported fields.");
|
|
12
|
+
}
|
|
13
|
+
return value as Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export function automationText(value: unknown, maximum = 256, empty = false): string {
|
|
16
|
+
if (typeof value !== "string" || (!empty && value.length === 0) || Buffer.byteLength(value) > maximum || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
|
|
17
|
+
|| Buffer.from(value, "utf8").toString("utf8") !== value) throw new Error("Messaging automation text is invalid or exceeds its bound.");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function automationId(value: unknown): string {
|
|
21
|
+
const id = automationText(value, 256);
|
|
22
|
+
if (!/^[A-Za-z0-9._:-]+$/u.test(id)) throw new Error("Messaging automation identifier is invalid.");
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
export function automationDigest(value: unknown): string {
|
|
26
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) throw new Error("Messaging automation digest is invalid.");
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
export function automationInteger(value: unknown, minimum: number, maximum: number): number {
|
|
30
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error("Messaging automation integer is outside its bound.");
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function automationArray(value: unknown, maximum: number): readonly unknown[] {
|
|
34
|
+
if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > maximum
|
|
35
|
+
|| Reflect.ownKeys(value).length !== value.length + 1) throw new Error("Messaging automation list is invalid or exceeds its bound.");
|
|
36
|
+
for (let i = 0; i < value.length; i++) {
|
|
37
|
+
const field = Object.getOwnPropertyDescriptor(value, String(i));
|
|
38
|
+
if (field === undefined || !field.enumerable || !("value" in field)) throw new Error("Messaging automation list must be dense data.");
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
export function automationDate(value: unknown): string {
|
|
43
|
+
const text = automationText(value, 32);
|
|
44
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(text) || !Number.isFinite(Date.parse(text)) || new Date(text).toISOString() !== text) throw new Error("Messaging automation time must be canonical UTC.");
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
export function parseAutomationCoordinate(value: unknown): AutomationCoordinate {
|
|
48
|
+
if (typeof value !== "object" || value === null || types.isProxy(value)) throw new Error("Messaging coordinate is invalid.");
|
|
49
|
+
const provider = Object.getOwnPropertyDescriptor(value, "provider")?.value;
|
|
50
|
+
if (provider === "imessage") {
|
|
51
|
+
const r = automationRecord(value, ["provider", "chatGuid", "service", "observedChatRowId"]);
|
|
52
|
+
const chatGuid = automationText(r.chatGuid, 1024);
|
|
53
|
+
if (!chatGuid.startsWith("iMessage;") || r.service !== "iMessage") throw new Error("Only exact iMessage conversations are supported.");
|
|
54
|
+
return Object.freeze({ provider, chatGuid, service: "iMessage", observedChatRowId: automationInteger(r.observedChatRowId, 1, Number.MAX_SAFE_INTEGER) });
|
|
55
|
+
}
|
|
56
|
+
const r = automationRecord(value, ["provider", "conversationJid"]);
|
|
57
|
+
if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || !/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid)) throw new Error("Only exact individual WhatsApp conversations are supported.");
|
|
58
|
+
return Object.freeze({ provider, conversationJid: r.conversationJid });
|
|
59
|
+
}
|
|
60
|
+
export function parseAutomationIdentity(value: unknown): AutomationIdentity {
|
|
61
|
+
const r = automationRecord(value, ["provider", "authId", "accountIdentity", "accountSubject", "implementationIdentity", "sourceGeneration"]);
|
|
62
|
+
if (r.provider !== "imessage" && r.provider !== "whatsapp") throw new Error("Messaging provider is invalid.");
|
|
63
|
+
return Object.freeze({ provider: r.provider, authId: automationId(r.authId), accountIdentity: automationDigest(r.accountIdentity), accountSubject: automationText(r.accountSubject, 512), implementationIdentity: automationDigest(r.implementationIdentity), sourceGeneration: automationText(r.sourceGeneration, 256) });
|
|
64
|
+
}
|
|
65
|
+
export function parseAutomationActionKind(value: unknown): AutomationActionKind {
|
|
66
|
+
if (!AUTOMATION_ACTION_KINDS.includes(value as AutomationActionKind)) throw new Error("Messaging action is unsupported.");
|
|
67
|
+
return value as AutomationActionKind;
|
|
68
|
+
}
|
|
69
|
+
function https(value: unknown): string {
|
|
70
|
+
const url = automationText(value, 4096);
|
|
71
|
+
const parsed = new URL(url);
|
|
72
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Messaging links require credential-free HTTPS.");
|
|
73
|
+
return url;
|
|
74
|
+
}
|
|
75
|
+
export function parseAutomationAction(value: unknown): AutomationAction {
|
|
76
|
+
if (typeof value !== "object" || value === null || types.isProxy(value)) throw new Error("Messaging action is invalid.");
|
|
77
|
+
const kind = parseAutomationActionKind(Object.getOwnPropertyDescriptor(value, "kind")?.value);
|
|
78
|
+
if (kind === "text") { const r = automationRecord(value, ["kind", "text"]); return Object.freeze({ kind, text: automationText(r.text, 65536) }); }
|
|
79
|
+
if (kind === "link" || kind === "app-clip") { const r = automationRecord(value, ["kind", "url"]); return Object.freeze({ kind, url: https(r.url) }); }
|
|
80
|
+
if (kind === "attachment") {
|
|
81
|
+
const r = automationRecord(value, ["kind", "assetId", "name", "mimeType"]);
|
|
82
|
+
const name = automationText(r.name, 255); const mimeType = automationText(r.mimeType, 128);
|
|
83
|
+
if (/[\\/\r\n]/u.test(name) || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/u.test(mimeType)) throw new Error("Messaging attachment metadata is invalid.");
|
|
84
|
+
return Object.freeze({ kind, assetId: automationId(r.assetId), name, mimeType });
|
|
85
|
+
}
|
|
86
|
+
if (kind === "reaction") {
|
|
87
|
+
const r = automationRecord(value, ["kind", "messageId", "emoji", "remove"]); const emoji = automationText(r.emoji, 64, true);
|
|
88
|
+
if (typeof r.remove !== "boolean" || (!r.remove && (emoji.length === 0 || [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(emoji)].length !== 1))) throw new Error("Messaging reaction is invalid.");
|
|
89
|
+
return Object.freeze({ kind, messageId: automationId(r.messageId), emoji, remove: r.remove });
|
|
90
|
+
}
|
|
91
|
+
if (kind === "sticker") { const r = automationRecord(value, ["kind", "assetId", "messageId"]); return Object.freeze({ kind, assetId: automationId(r.assetId), messageId: r.messageId === null ? null : automationId(r.messageId) }); }
|
|
92
|
+
if (kind === "poll") {
|
|
93
|
+
const r = automationRecord(value, ["kind", "question", "options", "maximumSelections"]);
|
|
94
|
+
const options = automationArray(r.options, 12).map(option => automationText(option, 256));
|
|
95
|
+
if (options.length < 2 || new Set(options).size !== options.length) throw new Error("Messaging poll needs distinct options.");
|
|
96
|
+
return Object.freeze({ kind, question: automationText(r.question, 1024), options: Object.freeze(options), maximumSelections: r.maximumSelections === null ? null : automationInteger(r.maximumSelections, 1, options.length) });
|
|
97
|
+
}
|
|
98
|
+
const r = automationRecord(value, ["kind", "experienceId", "parameters"]);
|
|
99
|
+
if (typeof r.parameters !== "object" || r.parameters === null || types.isProxy(r.parameters)) throw new Error("Messaging experience parameters are invalid.");
|
|
100
|
+
const keys = Object.keys(r.parameters); if (keys.length > 16) throw new Error("Messaging experience parameters exceed their bound.");
|
|
101
|
+
const parameters = automationRecord(r.parameters, keys);
|
|
102
|
+
return Object.freeze({ kind, experienceId: automationId(r.experienceId), parameters: Object.freeze(Object.fromEntries(keys.map(key => [automationId(key), automationText(parameters[key], 4096, true)]))) });
|
|
103
|
+
}
|
|
104
|
+
export function parseAutomationMessage(value: unknown): AutomationMessage {
|
|
105
|
+
const r = automationRecord(value, ["id", "coordinate", "direction", "occurredAt", "text", "kind", "relatedMessageId", "attachments"]);
|
|
106
|
+
if (r.direction !== "incoming" && r.direction !== "outgoing" && r.direction !== "unknown") throw new Error("Messaging authorship is invalid.");
|
|
107
|
+
if (r.kind !== "message" && r.kind !== "reaction" && r.kind !== "edit" && r.kind !== "delete") throw new Error("Messaging event kind is invalid.");
|
|
108
|
+
const attachments = automationArray(r.attachments, 20).map(value => { const a = automationRecord(value, ["name", "mimeType", "sizeBytes"]); return Object.freeze({ name: a.name === null ? null : automationText(a.name, 512), mimeType: a.mimeType === null ? null : automationText(a.mimeType, 256), sizeBytes: a.sizeBytes === null ? null : automationInteger(a.sizeBytes, 0, 1024 * 1024 * 1024) }); });
|
|
109
|
+
return Object.freeze({ id: automationId(r.id), coordinate: parseAutomationCoordinate(r.coordinate), direction: r.direction, occurredAt: automationDate(r.occurredAt), text: r.text === null ? null : automationText(r.text, 65536, true), kind: r.kind, relatedMessageId: r.relatedMessageId === null ? null : automationId(r.relatedMessageId), attachments: Object.freeze(attachments) });
|
|
110
|
+
}
|