@hraness/ghostget 0.18.0 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +7 -7
  3. package/dist/apple-photos-client.js +2 -1
  4. package/dist/beeper-client.js +2 -1
  5. package/dist/client.js +1 -0
  6. package/dist/imessage-direct-install-2gm3kge7.js +324 -0
  7. package/dist/index-01eeae9e.js +5 -0
  8. package/dist/index-2ymnp8xv.js +145 -0
  9. package/dist/index-en5hycxp.js +175 -0
  10. package/dist/{index-9wca02er.js → index-hfbygww8.js} +1 -1
  11. package/dist/index-n4szk3nw.js +50 -0
  12. package/dist/index-yq6maz71.js +1105 -0
  13. package/dist/index-z1w83f81.js +4 -0
  14. package/dist/index.js +4 -47
  15. package/dist/messaging-automation-api.js +21 -0
  16. package/dist/messaging-automation-j4274hvc.js +609 -0
  17. package/dist/messaging-native-install-8w1j36ah.js +16 -0
  18. package/dist/messaging.js +1 -0
  19. package/dist/omni-client.js +1 -0
  20. package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
  21. package/dist/whatsapp-client.js +1 -0
  22. package/docs/control-panel.md +2 -2
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +22 -2
  26. package/skills/ghostget/references/control-panel.md +1 -1
  27. package/skills/ghostget/references/install.md +5 -5
  28. package/skills/ghostget/references/linkedin-adapter.md +209 -13
  29. package/skills/ghostget/references/platform-patterns.md +1 -1
  30. package/src/args.ts +18 -5
  31. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  32. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  33. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  34. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  35. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  36. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  39. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  40. package/src/beeper-client-types.ts +1 -1
  41. package/src/ghostget.ts +16 -4
  42. package/src/messaging-automation-api.ts +12 -0
  43. package/src/messaging-automation-descriptors.ts +29 -0
  44. package/src/messaging-automation-factory.ts +148 -0
  45. package/src/messaging-automation-server.ts +183 -0
  46. package/src/messaging-automation-types.ts +155 -0
  47. package/src/messaging-automation-validation.ts +110 -0
  48. package/src/messaging-automation.ts +356 -0
  49. package/src/plugins/imessage-direct/plugin.ts +12 -1
  50. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  51. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  52. package/src/plugins/linkedin-web/plugin.ts +1 -1
  53. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  54. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  55. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  56. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  57. package/src/provider-plugin.ts +14 -0
  58. package/src/providers/imessage-automation.ts +250 -0
  59. package/src/providers/imessage-direct-install.ts +7 -0
  60. package/src/providers/imessage-direct-runtime.ts +32 -2
  61. package/src/providers/imessage-direct.ts +13 -3
  62. package/src/providers/linkedin-contact-failure.ts +21 -0
  63. package/src/providers/linkedin-contact-platform.ts +8 -1
  64. package/src/providers/linkedin-contact-program.ts +34 -5
  65. package/src/providers/linkedin-web-contact.ts +1023 -51
  66. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  67. package/src/providers/linkedin-web-runtime.ts +1 -0
  68. package/src/providers/linkedin-web.ts +46 -2
  69. package/src/providers/messaging-native-artifacts.ts +38 -0
  70. package/src/providers/messaging-native-install.ts +75 -0
  71. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  72. package/src/providers/whatsapp-automation.ts +153 -0
  73. package/src/usage.ts +3 -1
  74. package/src/version.ts +1 -1
  75. package/src/web-session-contract-definitions.ts +1 -1
  76. package/tsconfig.json +3 -0
@@ -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
+ }
@@ -0,0 +1,356 @@
1
+ import { Database, constants as sqlite } from "bun:sqlite";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
3
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { canonicalJson, sha256 } from "./canonical-json";
6
+ import { ensurePrivateStateDirectory, ghostgetStateHome, snapshotPrivateStateDirectory } from "./storage";
7
+ import { AUTOMATION_ACTION_KINDS, automationArray, automationDate, automationDigest, automationId, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationActionKind, parseAutomationCoordinate, parseAutomationIdentity, parseAutomationMessage } from "./messaging-automation-validation";
8
+ import type { AutomationConversation, AutomationEnrollment, AutomationEvent, AutomationGrant, AutomationGrantRequest, AutomationIdentity, AutomationPlan, AutomationPlanRequest, AutomationProviderPage, AutomationProviderSendResult, AutomationRun, MessagingAutomationProvider } from "./messaging-automation-types";
9
+ export * from "./messaging-automation-types";
10
+
11
+ type Environment = Readonly<Record<string, string | undefined>>;
12
+ type StoredEnrollment = { id: string; data: string; cursor: string; revision: number; ready: number; baselining: number; gap: number; reason: string | null };
13
+ type StoredGrant = { id: string; data: string; revoked: number; consumed: number; last_dispatch: number };
14
+ type StoredRun = { id: string; plan_id: string; intent_id: string; enrollment_id: string; state: AutomationRun["state"]; accepted: string; total: number; reason: string | null };
15
+ const RUN_COLUMNS = "id,plan_id,intent_id,enrollment_id,state,accepted,total,reason";
16
+ const same = (a: unknown, b: unknown) => canonicalJson(a) === canonicalJson(b);
17
+ const authority = (identity: AutomationIdentity, selected: AutomationConversation) => ({ identity, conversation: { ...selected, title: null } });
18
+ const sameConversation = (a: AutomationConversation, b: AutomationConversation) => same({ ...a, title: null }, { ...b, title: null });
19
+ const stopped = (signal?: AbortSignal) => { if (signal?.aborted) throw new Error("Messaging automation operation was cancelled."); };
20
+ const CAPACITY = Object.freeze({ enrollments: 1000, grants: 10000, plans: 20000, runs: 20000, messages: 50000, events: 50000 });
21
+ function grantData(value: unknown): AutomationGrantRequest {
22
+ const r = automationRecord(value, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]);
23
+ const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind);
24
+ if (actions.length === 0 || new Set(actions).size !== actions.length) throw new Error("Messaging grant action list is invalid.");
25
+ return Object.freeze({ enrollmentId: automationId(r.enrollmentId), expectedBindingDigest: automationDigest(r.expectedBindingDigest), actions: Object.freeze(actions), expiresAt: automationDate(r.expiresAt), maximumActions: automationInteger(r.maximumActions, 1, 100000), minimumIntervalMs: automationInteger(r.minimumIntervalMs, 0, 86400000) });
26
+ }
27
+ function planData(value: unknown): AutomationPlan {
28
+ const r = automationRecord(value, ["enrollmentId", "expectedRevision", "intentId", "actions", "bindingDigest", "expiresAt", "id", "digest"]);
29
+ const actions = automationArray(r.actions, 8).map(parseAutomationAction);
30
+ if (actions.length === 0 || Buffer.byteLength(canonicalJson(actions)) > 256 * 1024) throw new Error("Messaging plan exceeds its bound.");
31
+ const binding = { enrollmentId: automationId(r.enrollmentId), expectedRevision: automationInteger(r.expectedRevision, 0, Number.MAX_SAFE_INTEGER), intentId: automationId(r.intentId), actions: Object.freeze(actions), bindingDigest: automationDigest(r.bindingDigest), expiresAt: automationDate(r.expiresAt) };
32
+ const digest = automationDigest(r.digest); const id = automationId(r.id);
33
+ if (sha256(canonicalJson(binding)) !== digest || id !== `plan:${digest}`) throw new Error("Messaging plan binding is invalid.");
34
+ return Object.freeze({ ...binding, id, digest });
35
+ }
36
+
37
+ function conversation(value: unknown): AutomationConversation {
38
+ const r = automationRecord(value, ["coordinate", "title", "kind", "participants"]);
39
+ const coordinate = parseAutomationCoordinate(r.coordinate);
40
+ const participants = automationArray(r.participants, 2).map(value => automationText(value, 512));
41
+ if (r.kind !== "single" || participants.length < 1 || new Set(participants).size !== participants.length) throw new Error("Messaging automation requires one verified individual conversation.");
42
+ return Object.freeze({ coordinate, title: r.title === null ? null : automationText(r.title, 512), kind: "single", participants: Object.freeze([...participants].sort()) });
43
+ }
44
+ function checkedPage(value: unknown, expected: AutomationIdentity, coordinate: AutomationConversation["coordinate"]): AutomationProviderPage {
45
+ const r = automationRecord(value, ["identity", "messages", "nextCursor", "caughtUp", "gap"]);
46
+ const identity = parseAutomationIdentity(r.identity);
47
+ if (!same(identity, expected) || typeof r.caughtUp !== "boolean" || typeof r.gap !== "boolean") throw new Error("Messaging provider identity or cursor state changed.");
48
+ const messages = automationArray(r.messages, 500).map(parseAutomationMessage);
49
+ if (messages.some(message => !same(message.coordinate, coordinate))) throw new Error("Messaging provider returned another conversation.");
50
+ return Object.freeze({ identity, messages: Object.freeze(messages), nextCursor: automationText(r.nextCursor, 4096), caughtUp: r.caughtUp, gap: r.gap });
51
+ }
52
+ function checkedResult(value: unknown): AutomationProviderSendResult {
53
+ if (typeof value !== "object" || value === null) throw new Error("Messaging provider result is invalid.");
54
+ const state = Object.getOwnPropertyDescriptor(value, "state")?.value;
55
+ if (state === "accepted") {
56
+ const r = automationRecord(value, ["state", "messageId", "providerReceiptId", "delivery"]);
57
+ if (r.delivery !== "unknown") throw new Error("Messaging provider delivery claim is unsupported.");
58
+ return { state, messageId: r.messageId === null ? null : automationId(r.messageId), providerReceiptId: r.providerReceiptId === null ? null : automationId(r.providerReceiptId), delivery: "unknown" };
59
+ }
60
+ const r = automationRecord(value, ["state", "reason"]);
61
+ if (state !== "not-started" && state !== "indeterminate") throw new Error("Messaging provider result is invalid.");
62
+ return { state, reason: automationText(r.reason, 1024) };
63
+ }
64
+
65
+ /**
66
+ * Durable host coordination, not a model runtime or a replacement permission policy.
67
+ * The owner invokes grant/revoke through Ghostget's private control boundary. A grant
68
+ * narrows an already permitted provider operation to one immutable conversation.
69
+ * Multiple processes are serialized at each durable claim; an unresolved run fences
70
+ * its enrollment across restarts. No startup path clears uncertain dispatch state.
71
+ */
72
+ export class MessagingAutomationHost {
73
+ private readonly db: Database;
74
+ private readonly directory: string;
75
+ private readonly directoryIdentity: ReturnType<typeof ensurePrivateStateDirectory>;
76
+ private readonly fileIdentity: { dev: number; ino: number };
77
+ private readonly providers = new Map<string, MessagingAutomationProvider>();
78
+ private readonly cursorKey: Buffer;
79
+ private readonly controllers = new Set<AbortController>();
80
+ private readonly planControllers = new Map<string, Set<AbortController>>();
81
+ private readonly pending = new Set<Promise<unknown>>();
82
+ private closing = false;
83
+ private closed = false;
84
+ constructor(providers: readonly MessagingAutomationProvider[], private readonly environment: Environment = process.env, private readonly now: () => number = Date.now) {
85
+ if (providers.length > 16) throw new Error("Too many messaging providers.");
86
+ for (const provider of providers) {
87
+ if (this.providers.has(provider.provider)) throw new Error("Duplicate messaging provider.");
88
+ this.providers.set(provider.provider, provider);
89
+ }
90
+ this.directory = join(ghostgetStateHome(environment), "messaging", "automation");
91
+ this.directoryIdentity = ensurePrivateStateDirectory(this.directory, environment);
92
+ const path = join(this.directory, "host.sqlite");
93
+ const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
94
+ try { const stat = fstatSync(fd); if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1) throw new Error("Unsafe messaging journal."); this.fileIdentity = { dev: stat.dev, ino: stat.ino }; } finally { closeSync(fd); }
95
+ this.checkFiles();
96
+ this.db = new Database(path, sqlite.SQLITE_OPEN_READWRITE | sqlite.SQLITE_OPEN_NOFOLLOW);
97
+ try {
98
+ this.db.exec("PRAGMA busy_timeout=250; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF; PRAGMA secure_delete=ON; PRAGMA max_page_count=65536;");
99
+ const version = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version;
100
+ if (version !== 0 && version !== 1 && version !== 2) throw new Error("Unsupported messaging journal schema.");
101
+ this.db.exec("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS enrollments (id TEXT PRIMARY KEY,data TEXT NOT NULL,cursor TEXT NOT NULL,revision INTEGER NOT NULL,ready INTEGER NOT NULL,reason TEXT); CREATE TABLE IF NOT EXISTS grants (id TEXT PRIMARY KEY,data TEXT NOT NULL,revoked INTEGER NOT NULL,consumed INTEGER NOT NULL,last_dispatch INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS plans (id TEXT PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,plan_id TEXT NOT NULL,intent_id TEXT NOT NULL UNIQUE,enrollment_id TEXT NOT NULL,state TEXT NOT NULL,accepted TEXT NOT NULL,total INTEGER NOT NULL,reason TEXT); CREATE INDEX IF NOT EXISTS runs_contact ON runs(enrollment_id,state); CREATE TABLE IF NOT EXISTS messages (enrollment_id TEXT NOT NULL,event_key TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(enrollment_id,event_key)); CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT,enrollment_id TEXT NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL);");
102
+ // Upgrading an old cursor establishes a fresh live boundary. It must not
103
+ // turn its unconsumed historical backlog into new automatic replies.
104
+ if (version !== 2) this.db.transaction(() => {
105
+ this.db.exec("ALTER TABLE enrollments ADD COLUMN baselining INTEGER NOT NULL DEFAULT 1; ALTER TABLE enrollments ADD COLUMN gap INTEGER NOT NULL DEFAULT 0; UPDATE enrollments SET gap=CASE WHEN reason LIKE '%unresolved gap%' THEN 1 ELSE 0 END,ready=0; PRAGMA user_version=2;");
106
+ }).immediate();
107
+ else this.db.exec("PRAGMA user_version=2;");
108
+ this.db.exec("CREATE TABLE IF NOT EXISTS grant_intents (id TEXT PRIMARY KEY,request_digest TEXT NOT NULL,grant_id TEXT NOT NULL UNIQUE REFERENCES grants(id));");
109
+ this.db.query("INSERT OR IGNORE INTO metadata(key,value) VALUES('cursor-key',?)").run(randomBytes(32).toString("hex"));
110
+ this.cursorKey = Buffer.from(automationDigest(this.db.query<{ value: string }, []>("SELECT value FROM metadata WHERE key='cursor-key'").get()?.value), "hex");
111
+ this.checkFiles();
112
+ } catch (error) { this.db.close(); throw error; }
113
+ }
114
+ private checkFiles(): void {
115
+ snapshotPrivateStateDirectory(this.directory, this.environment, this.directoryIdentity);
116
+ for (const name of ["host.sqlite", "host.sqlite-journal", "host.sqlite-wal", "host.sqlite-shm"]) {
117
+ let stat: ReturnType<typeof lstatSync>; try { stat = lstatSync(join(this.directory, name)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" && name !== "host.sqlite") continue; throw error; }
118
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || name.endsWith("-wal") || name.endsWith("-shm")
119
+ || (name === "host.sqlite" && (stat.dev !== this.fileIdentity.dev || stat.ino !== this.fileIdentity.ino))) throw new Error("Messaging journal filesystem identity changed.");
120
+ }
121
+ }
122
+ private ready(): void { if (this.closing || this.closed) throw new Error("Messaging automation host is closed."); this.checkFiles(); }
123
+ private capacity(table: keyof typeof CAPACITY, additional = 1): void {
124
+ const count = this.db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n;
125
+ const pages = this.db.query<{ page_count: number }, []>("PRAGMA page_count").get()!.page_count;
126
+ const size = this.db.query<{ page_size: number }, []>("PRAGMA page_size").get()!.page_size;
127
+ // Keep substantial physical headroom for dispatch/result journals. Nothing
128
+ // evicts receipts or advances consumer cursors silently when the bound fills.
129
+ if (count + additional > CAPACITY[table] || pages * size >= 192 * 1024 * 1024) throw new Error("Messaging journal capacity reached; owner maintenance is required before further dispatch.");
130
+ }
131
+ private provider(id: string): MessagingAutomationProvider { const provider = this.providers.get(id); if (provider === undefined) throw new Error("Messaging provider is unavailable."); return provider; }
132
+ private row(id: string): StoredEnrollment { this.ready(); const row = this.db.query<StoredEnrollment, [string]>("SELECT * FROM enrollments WHERE id=?").get(automationId(id)); if (row === null) throw new Error("Messaging enrollment does not exist."); return row; }
133
+ private enrollment(row: StoredEnrollment): AutomationEnrollment {
134
+ const r = automationRecord(JSON.parse(row.data) as unknown, ["identity", "conversation", "bindingDigest"]);
135
+ const identity = parseAutomationIdentity(r.identity); const selected = conversation(r.conversation); const bindingDigest = automationDigest(r.bindingDigest);
136
+ if (sha256(canonicalJson(authority(identity, selected))) !== bindingDigest) throw new Error("Messaging enrollment binding is invalid.");
137
+ const ready = automationInteger(row.ready, 0, 1); const baselining = automationInteger(row.baselining, 0, 1); const gap = automationInteger(row.gap, 0, 1);
138
+ if (ready && (baselining || gap)) throw new Error("Messaging enrollment readiness is inconsistent.");
139
+ return Object.freeze({ id: automationId(row.id), identity, conversation: selected, bindingDigest, revision: automationInteger(row.revision, 0, Number.MAX_SAFE_INTEGER), ready: ready === 1, reason: row.reason === null ? null : automationText(row.reason, 1024) });
140
+ }
141
+ enrollments(): readonly AutomationEnrollment[] { this.ready(); return this.db.query<StoredEnrollment, []>("SELECT * FROM enrollments ORDER BY id LIMIT 1001").all().map(row => this.enrollment(row)); }
142
+ private async status(provider: MessagingAutomationProvider, signal?: AbortSignal) {
143
+ stopped(signal); const value = await provider.inspect(signal); stopped(signal);
144
+ const r = automationRecord(value, ["identity", "connected", "events", "actions"]); const identity = parseAutomationIdentity(r.identity);
145
+ if (identity.provider !== provider.provider || typeof r.connected !== "boolean") throw new Error("Messaging provider identity is invalid.");
146
+ const actions = automationRecord(r.actions, AUTOMATION_ACTION_KINDS);
147
+ for (const capability of [r.events, ...Object.values(actions)]) { const c = automationRecord(capability, ["available", "reason"]); if (typeof c.available !== "boolean") throw new Error("Messaging capability is invalid."); if (c.reason !== null) automationText(c.reason, 1024); }
148
+ return value;
149
+ }
150
+ async providerStatus(id: string, signal?: AbortSignal) {
151
+ this.ready(); return this.status(this.provider(automationId(id)), signal);
152
+ }
153
+ async conversations(raw: Readonly<{ provider: string; limit: number }>, signal?: AbortSignal) {
154
+ this.ready(); const r = automationRecord(raw, ["provider", "limit"]);
155
+ const provider = this.provider(automationId(r.provider)), limit = automationInteger(r.limit, 1, 200);
156
+ const status = await this.status(provider, signal);
157
+ const result = automationRecord(await provider.conversations({ limit }, signal), ["identity", "conversations", "complete"]); stopped(signal);
158
+ const identity = parseAutomationIdentity(result.identity);
159
+ if (!same(identity, status.identity) || typeof result.complete !== "boolean") throw new Error("Messaging discovery identity changed.");
160
+ const rows = automationArray(result.conversations, limit).filter(value => automationRecord(value, ["coordinate", "title", "kind", "participants"]).kind === "single").map(conversation);
161
+ if (rows.some(row => row.coordinate.provider !== identity.provider) || new Set(rows.map(row => canonicalJson(row.coordinate))).size !== rows.length) throw new Error("Messaging discovery contains conflicting conversations.");
162
+ return Object.freeze({ identity, conversations: Object.freeze(rows), complete: result.complete });
163
+ }
164
+ history(raw: Readonly<{ enrollmentId: string; limit: number }>) {
165
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "limit"]), limit = automationInteger(r.limit, 1, 200);
166
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId)));
167
+ const rows = this.db.query<{ data: string }, [string, number]>("SELECT data FROM messages WHERE rowid IN (SELECT MAX(rowid) FROM messages WHERE enrollment_id=? GROUP BY json_extract(data,'$.id')) ORDER BY json_extract(data,'$.occurredAt') DESC,rowid DESC LIMIT ?").all(enrollment.id, limit);
168
+ const messages = rows.map(row => parseAutomationMessage(JSON.parse(row.data) as unknown)).reverse();
169
+ if (messages.some(message => !same(message.coordinate, enrollment.conversation.coordinate))) throw new Error("Messaging stored history has another conversation.");
170
+ return Object.freeze({ enrollment, messages: Object.freeze(messages) });
171
+ }
172
+ async enroll(raw: Readonly<{ provider: string; coordinate: unknown }>, signal?: AbortSignal): Promise<AutomationEnrollment> {
173
+ this.ready(); const request = automationRecord(raw, ["provider", "coordinate"]); const coordinate = parseAutomationCoordinate(request.coordinate);
174
+ if (request.provider !== coordinate.provider) throw new Error("Messaging provider and coordinate disagree.");
175
+ const provider = this.provider(coordinate.provider); const status = await this.status(provider, signal);
176
+ const resolved = await provider.resolve(coordinate, signal); stopped(signal);
177
+ if (!same(parseAutomationIdentity(resolved.identity), status.identity)) throw new Error("Messaging identity changed during enrollment.");
178
+ const selected = conversation(resolved.conversation); if (!same(selected.coordinate, coordinate)) throw new Error("Messaging route resolution changed target.");
179
+ const history = checkedPage(await provider.history({ coordinate, limit: 200 }, signal), status.identity, coordinate); stopped(signal);
180
+ const binding = { identity: status.identity, conversation: selected }; const bindingDigest = sha256(canonicalJson(authority(status.identity, selected)));
181
+ const id = `enrollment:${randomUUID()}`;
182
+ this.ready(); this.db.transaction(() => {
183
+ this.capacity("enrollments"); this.capacity("messages", history.messages.length);
184
+ if (this.enrollments().some(enrollment => same(enrollment.identity, status.identity) && same(enrollment.conversation.coordinate, coordinate))) throw new Error("Conversation is already enrolled.");
185
+ this.db.query("INSERT INTO enrollments(id,data,cursor,revision,ready,reason,baselining,gap) VALUES(?,?,?,0,?,?,?,?)").run(id, canonicalJson({ ...binding, bindingDigest }), history.nextCursor, !history.gap && history.caughtUp ? 1 : 0, history.gap ? "Provider history has an unresolved gap." : history.caughtUp ? null : "Initial history catchup is incomplete.", history.caughtUp ? 0 : 1, history.gap ? 1 : 0);
186
+ for (const message of history.messages) this.db.query("INSERT OR IGNORE INTO messages VALUES(?,?,?)").run(id, sha256(canonicalJson(message)), canonicalJson(message));
187
+ }).immediate(); this.checkFiles();
188
+ return this.enrollment(this.row(id));
189
+ }
190
+ grant(raw: AutomationGrantRequest, intent?: string): AutomationGrant {
191
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "expectedBindingDigest", "actions", "expiresAt", "maximumActions", "minimumIntervalMs"]);
192
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedBindingDigest = automationDigest(r.expectedBindingDigest);
193
+ if (enrollment.bindingDigest !== expectedBindingDigest) throw new Error("Messaging enrollment changed before grant issuance.");
194
+ const actions = automationArray(r.actions, AUTOMATION_ACTION_KINDS.length).map(parseAutomationActionKind);
195
+ if (actions.length === 0 || new Set(actions).size !== actions.length) throw new Error("Messaging grant action list is invalid.");
196
+ const expiresAt = automationDate(r.expiresAt);
197
+ const grant = { enrollmentId: enrollment.id, expectedBindingDigest, actions: [...actions].sort(), expiresAt, maximumActions: automationInteger(r.maximumActions, 1, 100000), minimumIntervalMs: automationInteger(r.minimumIntervalMs, 0, 86400000) };
198
+ const intentId = intent === undefined ? null : automationId(intent), digest = sha256(canonicalJson(grant));
199
+ const result = this.db.transaction(() => {
200
+ if (intentId !== null) {
201
+ const previous = this.db.query<{ request_digest: string; grant_id: string }, [string]>("SELECT request_digest,grant_id FROM grant_intents WHERE id=?").get(intentId);
202
+ if (previous) {
203
+ if (automationDigest(previous.request_digest) !== digest) throw new Error("Messaging grant intent changed its request.");
204
+ return this.grantStatus(previous.grant_id);
205
+ }
206
+ }
207
+ if (Date.parse(expiresAt) <= this.now() || Date.parse(expiresAt) > this.now() + 30 * 86400000) throw new Error("Messaging grant must expire within 30 days.");
208
+ const id = `grant:${randomUUID()}`;
209
+ this.capacity("grants"); this.db.query("INSERT INTO grants VALUES(?,?,0,0,0)").run(id, canonicalJson(grant));
210
+ if (intentId !== null) this.db.query("INSERT INTO grant_intents VALUES(?,?,?)").run(intentId, digest, id);
211
+ return Object.freeze({ ...grant, id, revoked: false, consumedActions: 0 });
212
+ }).immediate(); this.checkFiles(); return result;
213
+ }
214
+ /** Resolves uncertain grant creation without issuing or renewing authority. */
215
+ grantByIntent(intentId: string): AutomationGrant | null {
216
+ this.ready(); const row = this.db.query<{ grant_id: string }, [string]>("SELECT grant_id FROM grant_intents WHERE id=?").get(automationId(intentId));
217
+ return row === null ? null : this.grantStatus(row.grant_id);
218
+ }
219
+ revoke(id: string): void { this.ready(); if (this.db.query("UPDATE grants SET revoked=1 WHERE id=?").run(automationId(id)).changes !== 1) throw new Error("Messaging grant does not exist."); this.checkFiles(); }
220
+ grantStatus(id: string): AutomationGrant {
221
+ this.ready(); const row = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(automationId(id));
222
+ if (row === null) throw new Error("Messaging grant does not exist.");
223
+ const grant = grantData(JSON.parse(row.data) as unknown);
224
+ return Object.freeze({ ...grant, id: automationId(row.id), revoked: automationInteger(row.revoked, 0, 1) === 1, consumedActions: automationInteger(row.consumed, 0, grant.maximumActions) });
225
+ }
226
+ private activeRun(enrollmentId: string): boolean { return this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM runs WHERE enrollment_id=? AND state IN ('started','partial','indeterminate')").get(enrollmentId)!.n > 0; }
227
+ async poll(enrollmentId: string, signal?: AbortSignal): Promise<AutomationEnrollment> {
228
+ const initial = this.row(enrollmentId); const enrollment = this.enrollment(initial);
229
+ if (this.activeRun(enrollmentId)) return enrollment;
230
+ const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal);
231
+ if (!same(status.identity, enrollment.identity)) { this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run("Provider identity changed; enroll the conversation again.", enrollmentId); throw new Error("Messaging provider identity changed."); }
232
+ if (!status.connected || !status.events.available) { this.db.query("UPDATE enrollments SET ready=0,reason=? WHERE id=?").run(status.events.reason ?? "Provider events are unavailable.", enrollmentId); return this.enrollment(this.row(enrollmentId)); }
233
+ const page = checkedPage(await provider.events({ coordinates: [enrollment.conversation.coordinate], cursor: initial.cursor, limit: 200 }, signal), enrollment.identity, enrollment.conversation.coordinate); stopped(signal);
234
+ this.ready(); this.db.transaction(() => {
235
+ const current = this.row(enrollmentId); if (current.cursor !== initial.cursor || current.revision !== initial.revision || current.baselining !== initial.baselining || current.gap !== initial.gap || this.activeRun(enrollmentId)) throw new Error("Messaging poll lost its concurrent cursor claim.");
236
+ this.capacity("messages", page.messages.length); if (!initial.baselining) this.capacity("events", page.messages.length);
237
+ let revision = initial.revision;
238
+ for (const [index, message] of page.messages.entries()) {
239
+ const data = canonicalJson(message);
240
+ const previous = this.db.query<{ data: string }, [string, string]>("SELECT data FROM messages WHERE enrollment_id=? AND json_extract(data,'$.id')=? ORDER BY rowid DESC LIMIT 1").get(enrollmentId, message.id);
241
+ if (previous?.data === data) continue;
242
+ // A→B→A is another change, not a lifetime duplicate. The provider cursor
243
+ // and page position identify this observation while latest state dedups
244
+ // overlap at page boundaries.
245
+ this.db.query("INSERT INTO messages VALUES(?,?,?)").run(enrollmentId, sha256(canonicalJson({ cursor: page.nextCursor, index, message })), data);
246
+ if (initial.baselining === 1) continue;
247
+ revision = automationInteger(revision + 1, 1, Number.MAX_SAFE_INTEGER);
248
+ this.db.query("INSERT INTO events(enrollment_id,revision,data) VALUES(?,?,?)").run(enrollmentId, revision, data);
249
+ }
250
+ const gap = initial.gap === 1 || page.gap;
251
+ this.db.query("UPDATE enrollments SET cursor=?,revision=?,ready=?,reason=?,baselining=?,gap=? WHERE id=?").run(page.nextCursor, revision, !gap && page.caughtUp ? 1 : 0, gap ? "Provider event stream has an unresolved gap." : page.caughtUp ? null : "Provider catchup is incomplete.", initial.baselining && !page.caughtUp ? 1 : 0, gap ? 1 : 0, enrollmentId);
252
+ }).immediate(); this.checkFiles(); return this.enrollment(this.row(enrollmentId));
253
+ }
254
+ events(raw: Readonly<{ enrollmentIds: readonly string[]; cursor: string | null; limit: number }>): Readonly<{ events: readonly AutomationEvent[]; nextCursor: string; caughtUp: boolean }> {
255
+ this.ready(); const r = automationRecord(raw, ["enrollmentIds", "cursor", "limit"]); const ids = automationArray(r.enrollmentIds, 100).map(automationId).sort();
256
+ if (ids.length === 0 || new Set(ids).size !== ids.length) throw new Error("Messaging event selection is invalid.");
257
+ for (const id of ids) this.row(id);
258
+ const limit = automationInteger(r.limit, 1, 500); const fingerprint = sha256(canonicalJson(ids)); let after = 0;
259
+ if (r.cursor !== null) {
260
+ const cursor = automationText(r.cursor, 4096); const [payload, signature, extra] = cursor.split(".");
261
+ if (payload === undefined || signature === undefined || extra !== undefined || !/^[a-f0-9]{64}$/u.test(signature)) throw new Error("Messaging cursor is invalid.");
262
+ const mac = createHmac("sha256", this.cursorKey).update(payload).digest(); if (!timingSafeEqual(Buffer.from(signature, "hex"), mac)) throw new Error("Messaging cursor is invalid.");
263
+ const decoded = automationRecord(JSON.parse(Buffer.from(payload, "base64url").toString()) as unknown, ["fingerprint", "after"]);
264
+ if (decoded.fingerprint !== fingerprint) throw new Error("Messaging cursor belongs to a different contact selection."); after = automationInteger(decoded.after, 0, Number.MAX_SAFE_INTEGER);
265
+ }
266
+ const rows = this.db.query<{ seq: number; enrollment_id: string; revision: number; data: string }, (string | number)[]>(`SELECT seq,enrollment_id,revision,data FROM events WHERE seq>? AND enrollment_id IN (${ids.map(() => "?").join(",")}) ORDER BY seq LIMIT ?`).all(after, ...ids, limit + 1);
267
+ const selected = rows.slice(0, limit); const payload = Buffer.from(canonicalJson({ fingerprint, after: selected.at(-1)?.seq ?? after })).toString("base64url");
268
+ return Object.freeze({ events: Object.freeze(selected.map(row => Object.freeze({ sequence: row.seq, enrollmentId: row.enrollment_id, revision: row.revision, message: parseAutomationMessage(JSON.parse(row.data) as unknown) }))), nextCursor: `${payload}.${createHmac("sha256", this.cursorKey).update(payload).digest("hex")}`, caughtUp: rows.length <= limit });
269
+ }
270
+ prepare(raw: AutomationPlanRequest): AutomationPlan {
271
+ this.ready(); const r = automationRecord(raw, ["enrollmentId", "expectedRevision", "intentId", "actions"]);
272
+ const enrollment = this.enrollment(this.row(automationId(r.enrollmentId))); const expectedRevision = automationInteger(r.expectedRevision, 0, Number.MAX_SAFE_INTEGER);
273
+ if (!enrollment.ready || enrollment.revision !== expectedRevision) throw new Error("Messaging context is stale or unavailable.");
274
+ const actions = automationArray(r.actions, 8).map(parseAutomationAction); if (actions.length === 0) throw new Error("Messaging plan is empty.");
275
+ if (Buffer.byteLength(canonicalJson(actions)) > 256 * 1024) throw new Error("Messaging plan exceeds its byte bound.");
276
+ const intentId = automationId(r.intentId); const binding = { enrollmentId: enrollment.id, expectedRevision, intentId, actions, bindingDigest: enrollment.bindingDigest, expiresAt: new Date(this.now() + 120000).toISOString() };
277
+ const digest = sha256(canonicalJson(binding)); const plan = Object.freeze({ ...binding, id: `plan:${digest}`, digest });
278
+ this.db.transaction(() => { this.capacity("plans"); this.db.query("INSERT OR IGNORE INTO plans VALUES(?,?)").run(plan.id, canonicalJson(plan)); }).immediate(); this.checkFiles(); return plan;
279
+ }
280
+ private runProjection(row: StoredRun): AutomationRun {
281
+ const accepted = automationArray(JSON.parse(row.accepted) as unknown, 8).map(value => { const r = automationRecord(value, ["messageId", "providerReceiptId"]); return Object.freeze({ messageId: r.messageId === null ? null : automationId(r.messageId), providerReceiptId: r.providerReceiptId === null ? null : automationId(r.providerReceiptId) }); });
282
+ const totalActions = automationInteger(row.total, 1, 8);
283
+ if (!["started", "accepted", "failed", "partial", "indeterminate"].includes(row.state) || accepted.length > totalActions || (row.state === "accepted" && accepted.length !== totalActions) || (row.state === "failed" && accepted.length !== 0)) throw new Error("Messaging run state is inconsistent.");
284
+ return Object.freeze({ id: automationId(row.id), planId: automationId(row.plan_id), intentId: automationId(row.intent_id), enrollmentId: automationId(row.enrollment_id), state: row.state, accepted: Object.freeze(accepted), totalActions, reason: row.reason === null ? null : automationText(row.reason, 1024), retryable: false });
285
+ }
286
+ run(id: string): AutomationRun { this.ready(); const row = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE id=?`).get(automationId(id)); if (row === null) throw new Error("Messaging run does not exist."); return this.runProjection(row); }
287
+ cancel(planId: string): boolean {
288
+ this.ready(); const controllers = this.planControllers.get(automationId(planId));
289
+ if (controllers === undefined) return false;
290
+ for (const controller of controllers) controller.abort(); return true;
291
+ }
292
+ submit(raw: Readonly<{ planId: string; grantId: string }>, signal?: AbortSignal): Promise<AutomationRun> {
293
+ this.ready(); const r = automationRecord(raw, ["planId", "grantId"]); const controller = new AbortController(); const abort = () => controller.abort(); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) controller.abort(); this.controllers.add(controller);
294
+ const planId = automationId(r.planId), grantId = automationId(r.grantId);
295
+ const owned = this.planControllers.get(planId) ?? new Set<AbortController>(); owned.add(controller); this.planControllers.set(planId, owned);
296
+ const operation = this.dispatch(planId, grantId, controller.signal);
297
+ this.pending.add(operation); void operation.finally(() => { signal?.removeEventListener("abort", abort); this.controllers.delete(controller); owned.delete(controller); if (!owned.size) this.planControllers.delete(planId); this.pending.delete(operation); }).catch(() => undefined); return operation;
298
+ }
299
+ private async dispatch(planId: string, grantId: string, signal: AbortSignal): Promise<AutomationRun> {
300
+ stopped(signal); const record = this.db.query<{ data: string }, [string]>("SELECT data FROM plans WHERE id=?").get(planId); if (record === null) throw new Error("Messaging plan does not exist.");
301
+ const plan = planData(JSON.parse(record.data) as unknown);
302
+ if (plan.id !== planId) throw new Error("Messaging plan binding is invalid.");
303
+ const existing = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE intent_id=?`).get(plan.intentId);
304
+ if (existing !== null) { if (existing.plan_id !== plan.id) throw new Error("Messaging intent already belongs to another plan."); return this.runProjection(existing); }
305
+ const enrollment = await this.poll(plan.enrollmentId, signal); const provider = this.provider(enrollment.identity.provider); const status = await this.status(provider, signal);
306
+ const route = await provider.resolve(enrollment.conversation.coordinate, signal); stopped(signal);
307
+ if (!same(parseAutomationIdentity(route.identity), enrollment.identity) || !sameConversation(conversation(route.conversation), enrollment.conversation) || !same(status.identity, enrollment.identity) || !status.connected) throw new Error("Messaging route or identity changed before dispatch.");
308
+ for (const action of plan.actions) if (!status.actions[parseAutomationAction(action).kind].available) throw new Error("Messaging action is unavailable for this provider.");
309
+ const runId = `run:${randomUUID()}`;
310
+ this.ready(); this.db.transaction(() => {
311
+ stopped(signal); const current = this.enrollment(this.row(plan.enrollmentId)); const now = this.now();
312
+ this.capacity("runs"); this.capacity("messages", 0); this.capacity("events", 0);
313
+ if (!current.ready || current.revision !== plan.expectedRevision || current.bindingDigest !== plan.bindingDigest || Date.parse(plan.expiresAt) <= now || this.activeRun(current.id)) throw new Error("Messaging context expired, changed, or has an unresolved run.");
314
+ const grantRow = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(grantId); if (grantRow === null) throw new Error("Messaging grant does not exist.");
315
+ const grant = grantData(JSON.parse(grantRow.data) as unknown);
316
+ automationInteger(grantRow.revoked, 0, 1); automationInteger(grantRow.consumed, 0, grant.maximumActions); automationInteger(grantRow.last_dispatch, 0, Number.MAX_SAFE_INTEGER);
317
+ if (grantRow.revoked !== 0 || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || Date.parse(grant.expiresAt) <= now
318
+ || grantRow.consumed + plan.actions.length > grant.maximumActions || (grantRow.last_dispatch !== 0 && now - grantRow.last_dispatch < grant.minimumIntervalMs)
319
+ || plan.actions.some(action => !grant.actions.includes(action.kind))) throw new Error("Messaging grant does not authorize this dispatch.");
320
+ this.db.query("INSERT INTO runs VALUES(?,?,?,?, 'started','[]',?,NULL)").run(runId, plan.id, plan.intentId, current.id, plan.actions.length);
321
+ this.db.query("UPDATE grants SET consumed=consumed+?,last_dispatch=? WHERE id=?").run(plan.actions.length, now, grantId);
322
+ }).immediate(); this.checkFiles();
323
+ const accepted: { messageId: string | null; providerReceiptId: string | null }[] = [];
324
+ let state: AutomationRun["state"] = "accepted"; let reason: string | null = null;
325
+ for (const [index, action] of plan.actions.entries()) {
326
+ try {
327
+ this.checkFiles();
328
+ const observed = checkedPage(await provider.events({ coordinates: [enrollment.conversation.coordinate], cursor: this.row(enrollment.id).cursor, limit: 200 }, signal), enrollment.identity, enrollment.conversation.coordinate);
329
+ const ownMessages = new Set(accepted.flatMap(receipt => receipt.messageId === null ? [] : [receipt.messageId]));
330
+ if (observed.gap || !observed.caughtUp || observed.messages.some(message => message.direction !== "outgoing" || message.kind !== "message" || !ownMessages.has(message.id))) {
331
+ state = accepted.length ? "partial" : "failed"; reason = "Conversation changed before the next action."; break;
332
+ }
333
+ const grantRow = this.db.query<StoredGrant, [string]>("SELECT * FROM grants WHERE id=?").get(grantId);
334
+ const grant = grantRow === null ? null : grantData(JSON.parse(grantRow.data) as unknown);
335
+ const current = this.enrollment(this.row(plan.enrollmentId));
336
+ if (signal.aborted || grantRow?.revoked !== 0 || grant === null || Date.parse(grant.expiresAt) <= this.now() || Date.parse(plan.expiresAt) <= this.now()
337
+ || grant.enrollmentId !== current.id || grant.expectedBindingDigest !== current.bindingDigest || current.bindingDigest !== plan.bindingDigest || !grant.actions.includes(action.kind)) {
338
+ state = accepted.length ? "partial" : "failed"; reason = "Dispatch permission expired or changed before the next action."; break;
339
+ }
340
+ const result = checkedResult(await provider.send({ identity: enrollment.identity, coordinate: enrollment.conversation.coordinate, action, intentId: `${plan.intentId}:${index}` }, signal));
341
+ if (result.state !== "accepted") { state = result.state === "indeterminate" ? "indeterminate" : accepted.length ? "partial" : "failed"; reason = result.reason; break; }
342
+ accepted.push({ messageId: result.messageId, providerReceiptId: result.providerReceiptId });
343
+ this.checkFiles(); if (this.db.query("UPDATE runs SET accepted=? WHERE id=? AND state='started'").run(canonicalJson(accepted), runId).changes !== 1) throw new Error("Messaging result journal lost its claim.");
344
+ } catch { state = "indeterminate"; reason = "Provider dispatch or result recording did not settle conclusively."; break; }
345
+ }
346
+ this.checkFiles(); if (this.db.query("UPDATE runs SET state=?,accepted=?,reason=? WHERE id=? AND state='started'").run(state, canonicalJson(accepted), reason, runId).changes !== 1) throw new Error("Messaging terminal result could not be recorded.");
347
+ const row = this.db.query<StoredRun, [string]>(`SELECT ${RUN_COLUMNS} FROM runs WHERE id=?`).get(runId)!; return this.runProjection(row);
348
+ }
349
+ async close(): Promise<void> {
350
+ if (this.closed) return; if (this.closing) throw new Error("Messaging automation shutdown is already in progress."); this.closing = true;
351
+ for (const controller of this.controllers) controller.abort();
352
+ const settled = await Promise.allSettled([...this.pending]); const providers = await Promise.allSettled([...this.providers.values()].map(provider => provider.close()));
353
+ this.db.close(); this.closed = true;
354
+ if (settled.some(result => result.status === "rejected") || providers.some(result => result.status === "rejected")) throw new Error("Messaging automation shutdown did not establish complete cleanup.");
355
+ }
356
+ }
@@ -1,3 +1,4 @@
1
+ import { automationOperationDefinitions } from "../../messaging-automation-descriptors";
1
2
  import {
2
3
  defineProviderPlugin,
3
4
  lazyLocalCliRuntime,
@@ -108,6 +109,12 @@ export const imessageDirectPlugin = defineProviderPlugin({
108
109
  sourceKind: "built-in",
109
110
  implementationSources: Object.freeze([
110
111
  ...providerImplementationEntry(import.meta.url),
112
+ Object.freeze({ label: "native/imsg-darwin-arm64.gz", url: new URL("../../assets/messaging-runtime/imsg-darwin-arm64.gz", import.meta.url) }),
113
+ Object.freeze({ label: "native/phone-number-metadata.json.gz", url: new URL("../../assets/messaging-runtime/phone-number-metadata.json.gz", import.meta.url) }),
114
+ Object.freeze({ label: "native/phone-number-privacy.plist.gz", url: new URL("../../assets/messaging-runtime/phone-number-privacy.plist.gz", import.meta.url) }),
115
+ Object.freeze({ label: "native/phone-number-info.plist.gz", url: new URL("../../assets/messaging-runtime/phone-number-info.plist.gz", import.meta.url) }),
116
+ Object.freeze({ label: "native/NOTICE.txt", url: new URL("../../assets/messaging-runtime/NOTICE.txt", import.meta.url) }),
117
+
111
118
  Object.freeze({
112
119
  label: "vendor/private-transport.patch",
113
120
  url: new URL(
@@ -122,6 +129,10 @@ export const imessageDirectPlugin = defineProviderPlugin({
122
129
  import.meta.url,
123
130
  ),
124
131
  }),
132
+ Object.freeze({
133
+ label: "vendor/no-fetch-rich-cards.patch",
134
+ url: new URL("./vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch", import.meta.url),
135
+ }),
125
136
  Object.freeze({
126
137
  label: "vendor/provenance.json",
127
138
  url: new URL("./vendor/provenance.json", import.meta.url),
@@ -143,7 +154,7 @@ export const imessageDirectPlugin = defineProviderPlugin({
143
154
  sourceUrl: IMSG_TOOL_PIN.sourceUrl,
144
155
  artifacts: IMSG_TOOL_PIN.artifacts,
145
156
  },
146
- operations,
157
+ operations: [...operations, ...automationOperationDefinitions("imessage")],
147
158
  subject: {
148
159
  format: "imessage:device-default:<sha256-messages-store-coordinate>",
149
160
  matches: (value) => /^imessage:device-default:[a-f0-9]{64}$/u.test(value),