@crewhaus/channel-adapter-imessage 0.1.3 → 0.1.5
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/dist/fixtures/build-chat-db.d.ts +1 -0
- package/dist/fixtures/build-chat-db.js +38 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +221 -0
- package/package.json +9 -6
- package/src/fixtures/build-chat-db.ts +0 -52
- package/src/index.osascript.test.ts +0 -116
- package/src/index.test.ts +0 -353
- package/src/index.ts +0 -361
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildFixtureChatDb(): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper — build a fixture chat.db with the minimum schema and
|
|
3
|
+
* representative rows. Returns the path to the temp .db file.
|
|
4
|
+
*/
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdtempSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
export function buildFixtureChatDb() {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-imessage-fix-"));
|
|
11
|
+
const path = join(dir, "chat.db");
|
|
12
|
+
const db = new Database(path);
|
|
13
|
+
db.run(`
|
|
14
|
+
CREATE TABLE handle (
|
|
15
|
+
ROWID INTEGER PRIMARY KEY,
|
|
16
|
+
id TEXT NOT NULL,
|
|
17
|
+
service TEXT NOT NULL DEFAULT 'iMessage'
|
|
18
|
+
);
|
|
19
|
+
`);
|
|
20
|
+
db.run(`
|
|
21
|
+
CREATE TABLE message (
|
|
22
|
+
ROWID INTEGER PRIMARY KEY,
|
|
23
|
+
text TEXT,
|
|
24
|
+
is_from_me INTEGER DEFAULT 0,
|
|
25
|
+
date INTEGER DEFAULT 0,
|
|
26
|
+
handle_id INTEGER REFERENCES handle(ROWID)
|
|
27
|
+
);
|
|
28
|
+
`);
|
|
29
|
+
db.run("INSERT INTO handle (ROWID, id) VALUES (1, 'alice@example.com')");
|
|
30
|
+
db.run("INSERT INTO handle (ROWID, id) VALUES (2, '+15551234567')");
|
|
31
|
+
db.run("INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (1, 'first inbound from alice', 0, 700000000000000000, 1)");
|
|
32
|
+
db.run("INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (2, 'me replying', 1, 700000001000000000, 1)");
|
|
33
|
+
db.run("INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (3, 'inbound from phone handle', 0, 700000002000000000, 2)");
|
|
34
|
+
db.run("INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (4, '', 0, 700000003000000000, 1)");
|
|
35
|
+
db.run("INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (5, 'another inbound', 0, 700000004000000000, 1)");
|
|
36
|
+
db.close();
|
|
37
|
+
return path;
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/channel-adapter-imessage — macOS-only iMessage channel
|
|
3
|
+
* adapter for the channel target (Section 33).
|
|
4
|
+
*
|
|
5
|
+
* Apple does NOT publish a public iMessage Business API for general
|
|
6
|
+
* agent integrations. This adapter takes a host-bound approach:
|
|
7
|
+
*
|
|
8
|
+
* - Inbound: poll Messages.app's SQLite store at
|
|
9
|
+
* ~/Library/Messages/chat.db for new rows since the last cursor.
|
|
10
|
+
* The cursor is `message.ROWID` — monotonically increasing per
|
|
11
|
+
* install. We persist it to a small JSON file so a daemon restart
|
|
12
|
+
* resumes from where it left off.
|
|
13
|
+
*
|
|
14
|
+
* - Outbound: drive Messages.app via osascript. The handle (an
|
|
15
|
+
* iMessage email/phone) is the conversation key.
|
|
16
|
+
*
|
|
17
|
+
* Hard requirements (enforced via guard / startup check):
|
|
18
|
+
* - Process is running on macOS (`process.platform === "darwin"`).
|
|
19
|
+
* - `CREWHAUS_IMESSAGE_HOST_ENABLED=1` is set — opt-in to using the
|
|
20
|
+
* host's logged-in iMessage account.
|
|
21
|
+
* - Full Disk Access permission is granted to the daemon's host
|
|
22
|
+
* terminal/process so chat.db is readable. (We surface a clear
|
|
23
|
+
* error if the file isn't reachable.)
|
|
24
|
+
*
|
|
25
|
+
* Path-traversal safety: chat.db's path is constructed from the HOME
|
|
26
|
+
* env at boot and validated against a fixed prefix
|
|
27
|
+
* (`~/Library/Messages/`). Custom override is allowed only via the
|
|
28
|
+
* adapter's `chatDbPath` option (intended for tests with fixture DBs)
|
|
29
|
+
* AND must resolve to a file with name `chat.db`.
|
|
30
|
+
*/
|
|
31
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
32
|
+
export declare class IMessageAdapterError extends CrewhausError {
|
|
33
|
+
readonly name = "IMessageAdapterError";
|
|
34
|
+
constructor(message: string, cause?: unknown);
|
|
35
|
+
}
|
|
36
|
+
export type RawRequest = {
|
|
37
|
+
readonly headers: Headers;
|
|
38
|
+
readonly body: string;
|
|
39
|
+
};
|
|
40
|
+
/** Channel-generic inbound event — same shape as Slack/Telegram/etc. */
|
|
41
|
+
export type InboundEvent = {
|
|
42
|
+
readonly idempotencyKey: string;
|
|
43
|
+
readonly workspaceId: string;
|
|
44
|
+
readonly channelId: string;
|
|
45
|
+
readonly userId: string;
|
|
46
|
+
readonly threadTs?: string;
|
|
47
|
+
readonly ts: string;
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly subtype: "app_mention" | "message";
|
|
50
|
+
};
|
|
51
|
+
export type ParsedInbound = {
|
|
52
|
+
readonly kind: "event";
|
|
53
|
+
readonly event: InboundEvent;
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: "skip";
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The iMessage adapter is non-webhook — there's no inbound HTTP. Instead
|
|
59
|
+
* the adapter exposes `pollNewMessages()` for the host to call; the
|
|
60
|
+
* generated channel daemon does NOT run a poll loop, so the caller is
|
|
61
|
+
* responsible for driving polling on whatever schedule it likes. We
|
|
62
|
+
* still implement the same `ChannelAdapter` interface so the §33
|
|
63
|
+
* multi-adapter wiring can register it; `verify` and `parseInbound` are
|
|
64
|
+
* never called by the gateway for iMessage (the host feeds the
|
|
65
|
+
* InboundEvents from `pollNewMessages()` into the session router).
|
|
66
|
+
*/
|
|
67
|
+
export interface ChannelAdapter {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
verify(req: RawRequest): boolean;
|
|
70
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
71
|
+
sendReply(args: {
|
|
72
|
+
event: InboundEvent;
|
|
73
|
+
text: string;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
setTyping(args: {
|
|
76
|
+
event: InboundEvent;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
export interface IMessageAdapter extends ChannelAdapter {
|
|
80
|
+
/**
|
|
81
|
+
* Poll chat.db for messages with ROWID > cursor. Returns the new
|
|
82
|
+
* messages and the new cursor. Idempotent across restarts: reads
|
|
83
|
+
* the persisted cursor from `cursorPath` if present.
|
|
84
|
+
*/
|
|
85
|
+
pollNewMessages(): Promise<{
|
|
86
|
+
readonly events: readonly InboundEvent[];
|
|
87
|
+
readonly cursor: number;
|
|
88
|
+
}>;
|
|
89
|
+
/** Returns the current persisted cursor (or 0 if not yet set). */
|
|
90
|
+
getCursor(): number;
|
|
91
|
+
/** Reset the cursor — used by tests + the `--reset-cursor` CLI flag. */
|
|
92
|
+
resetCursor(): void;
|
|
93
|
+
}
|
|
94
|
+
export type IMessageAdapterConfig = {
|
|
95
|
+
/**
|
|
96
|
+
* Custom chat.db path. Defaults to `<HOME>/Library/Messages/chat.db`.
|
|
97
|
+
* Overridable for tests (must end in `chat.db`).
|
|
98
|
+
*/
|
|
99
|
+
readonly chatDbPath?: string;
|
|
100
|
+
/** Where to persist the polling cursor. Defaults to `.crewhaus/imessage-cursor.json`. */
|
|
101
|
+
readonly cursorPath?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Required: gate the adapter on opt-in env. Setting this to false
|
|
104
|
+
* (e.g. in tests with fixture DBs) bypasses the
|
|
105
|
+
* `CREWHAUS_IMESSAGE_HOST_ENABLED=1` env-check. Defaults to true.
|
|
106
|
+
*/
|
|
107
|
+
readonly requireHostOptIn?: boolean;
|
|
108
|
+
};
|
|
109
|
+
export type IMessageAdapterOptions = {
|
|
110
|
+
/** Inject a custom osascript runner (defaults to spawning `osascript`). */
|
|
111
|
+
readonly osascript?: (script: string) => Promise<void>;
|
|
112
|
+
};
|
|
113
|
+
export declare function createIMessageAdapter(config?: IMessageAdapterConfig, opts?: IMessageAdapterOptions): IMessageAdapter;
|
|
114
|
+
/**
|
|
115
|
+
* Allow the canonical chat.db location (`<HOME>/Library/Messages/chat.db`)
|
|
116
|
+
* and any custom path that resolves to a file named `chat.db`. Reject
|
|
117
|
+
* absolute paths attempting traversal outside `~/Library/Messages/` UNLESS
|
|
118
|
+
* the caller explicitly overrode (tests use a tmpdir).
|
|
119
|
+
*/
|
|
120
|
+
declare function validateChatDbPath(path: string): string;
|
|
121
|
+
declare function isSafeHandle(handle: string): boolean;
|
|
122
|
+
declare function escapeAppleScriptString(s: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* Minimal structural type for the slice of `child_process.spawn` that the
|
|
125
|
+
* osascript runner uses. Keeping it explicit (rather than importing Node's
|
|
126
|
+
* `ChildProcess`) lets tests inject a deterministic fake without a real
|
|
127
|
+
* process spawn — mirroring the dependency-injection style used elsewhere in
|
|
128
|
+
* factory's adapters.
|
|
129
|
+
*/
|
|
130
|
+
type SpawnedChild = {
|
|
131
|
+
readonly stdout?: {
|
|
132
|
+
on(event: "data", cb: (chunk: Buffer) => void): void;
|
|
133
|
+
} | null;
|
|
134
|
+
readonly stderr: {
|
|
135
|
+
on(event: "data", cb: (chunk: Buffer) => void): void;
|
|
136
|
+
} | null;
|
|
137
|
+
readonly stdin: {
|
|
138
|
+
write(chunk: string): void;
|
|
139
|
+
end(): void;
|
|
140
|
+
} | null;
|
|
141
|
+
on(event: "error", cb: (err: Error) => void): void;
|
|
142
|
+
on(event: "close", cb: (code: number | null) => void): void;
|
|
143
|
+
};
|
|
144
|
+
export type OsascriptSpawn = (command: string, args: readonly string[], options: {
|
|
145
|
+
stdio: readonly ["pipe", "ignore", "pipe"];
|
|
146
|
+
}) => SpawnedChild;
|
|
147
|
+
/**
|
|
148
|
+
* Run `osascript` reading the script from stdin, using an injected `spawn`.
|
|
149
|
+
* Resolves on exit code 0; rejects with an {@link IMessageAdapterError} on a
|
|
150
|
+
* spawn failure or any non-zero exit (surfacing captured stderr).
|
|
151
|
+
*/
|
|
152
|
+
export declare function runOsascript(spawn: OsascriptSpawn, script: string): Promise<void>;
|
|
153
|
+
export declare const _internal: {
|
|
154
|
+
isSafeHandle: typeof isSafeHandle;
|
|
155
|
+
escapeAppleScriptString: typeof escapeAppleScriptString;
|
|
156
|
+
validateChatDbPath: typeof validateChatDbPath;
|
|
157
|
+
runOsascript: typeof runOsascript;
|
|
158
|
+
};
|
|
159
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, normalize } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* @crewhaus/channel-adapter-imessage — macOS-only iMessage channel
|
|
6
|
+
* adapter for the channel target (Section 33).
|
|
7
|
+
*
|
|
8
|
+
* Apple does NOT publish a public iMessage Business API for general
|
|
9
|
+
* agent integrations. This adapter takes a host-bound approach:
|
|
10
|
+
*
|
|
11
|
+
* - Inbound: poll Messages.app's SQLite store at
|
|
12
|
+
* ~/Library/Messages/chat.db for new rows since the last cursor.
|
|
13
|
+
* The cursor is `message.ROWID` — monotonically increasing per
|
|
14
|
+
* install. We persist it to a small JSON file so a daemon restart
|
|
15
|
+
* resumes from where it left off.
|
|
16
|
+
*
|
|
17
|
+
* - Outbound: drive Messages.app via osascript. The handle (an
|
|
18
|
+
* iMessage email/phone) is the conversation key.
|
|
19
|
+
*
|
|
20
|
+
* Hard requirements (enforced via guard / startup check):
|
|
21
|
+
* - Process is running on macOS (`process.platform === "darwin"`).
|
|
22
|
+
* - `CREWHAUS_IMESSAGE_HOST_ENABLED=1` is set — opt-in to using the
|
|
23
|
+
* host's logged-in iMessage account.
|
|
24
|
+
* - Full Disk Access permission is granted to the daemon's host
|
|
25
|
+
* terminal/process so chat.db is readable. (We surface a clear
|
|
26
|
+
* error if the file isn't reachable.)
|
|
27
|
+
*
|
|
28
|
+
* Path-traversal safety: chat.db's path is constructed from the HOME
|
|
29
|
+
* env at boot and validated against a fixed prefix
|
|
30
|
+
* (`~/Library/Messages/`). Custom override is allowed only via the
|
|
31
|
+
* adapter's `chatDbPath` option (intended for tests with fixture DBs)
|
|
32
|
+
* AND must resolve to a file with name `chat.db`.
|
|
33
|
+
*/
|
|
34
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
35
|
+
export class IMessageAdapterError extends CrewhausError {
|
|
36
|
+
name = "IMessageAdapterError";
|
|
37
|
+
constructor(message, cause) {
|
|
38
|
+
super("channel", message, cause);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const CHAT_DB_REL = "Library/Messages/chat.db";
|
|
42
|
+
export function createIMessageAdapter(config = {}, opts = {}) {
|
|
43
|
+
const requireHostOptIn = config.requireHostOptIn ?? true;
|
|
44
|
+
if (requireHostOptIn) {
|
|
45
|
+
if (process.platform !== "darwin") {
|
|
46
|
+
throw new IMessageAdapterError(`iMessage adapter requires macOS (process.platform=${process.platform})`);
|
|
47
|
+
}
|
|
48
|
+
if (process.env["CREWHAUS_IMESSAGE_HOST_ENABLED"] !== "1") {
|
|
49
|
+
throw new IMessageAdapterError("iMessage adapter requires CREWHAUS_IMESSAGE_HOST_ENABLED=1 (opt-in to host's logged-in iMessage)");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const home = process.env["HOME"] ?? "";
|
|
53
|
+
const defaultDbPath = home ? `${home}/${CHAT_DB_REL}` : "";
|
|
54
|
+
const chatDbPath = validateChatDbPath(config.chatDbPath ?? defaultDbPath);
|
|
55
|
+
if (!existsSync(chatDbPath)) {
|
|
56
|
+
throw new IMessageAdapterError(`iMessage chat.db not found at ${chatDbPath} (full disk access?)`);
|
|
57
|
+
}
|
|
58
|
+
const cursorPath = config.cursorPath ?? ".crewhaus/imessage-cursor.json";
|
|
59
|
+
const osascriptRun = opts.osascript ?? defaultOsascript;
|
|
60
|
+
return {
|
|
61
|
+
id: "imessage",
|
|
62
|
+
verify(_req) {
|
|
63
|
+
// No inbound HTTP — iMessage is polling-driven. We accept all by
|
|
64
|
+
// returning true so the gateway path never short-circuits, but
|
|
65
|
+
// the gateway ought to never call us anyway.
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
parseInbound(_req) {
|
|
69
|
+
return { kind: "skip" };
|
|
70
|
+
},
|
|
71
|
+
async sendReply(args) {
|
|
72
|
+
const handle = args.event.userId;
|
|
73
|
+
if (!isSafeHandle(handle)) {
|
|
74
|
+
throw new IMessageAdapterError(`unsafe iMessage handle: ${handle}`);
|
|
75
|
+
}
|
|
76
|
+
const escapedText = escapeAppleScriptString(args.text);
|
|
77
|
+
const escapedHandle = escapeAppleScriptString(handle);
|
|
78
|
+
const script = [
|
|
79
|
+
'tell application "Messages"',
|
|
80
|
+
" set targetService to 1st service whose service type = iMessage",
|
|
81
|
+
` set targetBuddy to buddy "${escapedHandle}" of targetService`,
|
|
82
|
+
` send "${escapedText}" to targetBuddy`,
|
|
83
|
+
"end tell",
|
|
84
|
+
].join("\n");
|
|
85
|
+
await osascriptRun(script);
|
|
86
|
+
},
|
|
87
|
+
async setTyping(_args) {
|
|
88
|
+
// Messages.app does not expose a typing-indicator API to AppleScript.
|
|
89
|
+
},
|
|
90
|
+
pollNewMessages() {
|
|
91
|
+
const cursor = readCursor(cursorPath);
|
|
92
|
+
const db = new Database(chatDbPath, { readonly: true });
|
|
93
|
+
try {
|
|
94
|
+
const rows = db.query(MESSAGE_QUERY).all(cursor);
|
|
95
|
+
const events = [];
|
|
96
|
+
let maxId = cursor;
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (row.ROWID > maxId)
|
|
99
|
+
maxId = row.ROWID;
|
|
100
|
+
if (row.is_from_me === 1)
|
|
101
|
+
continue;
|
|
102
|
+
const text = row.text ?? "";
|
|
103
|
+
if (text.trim() === "")
|
|
104
|
+
continue;
|
|
105
|
+
const handle = row.handle_id_str ?? `handle:${row.handle_id ?? "unknown"}`;
|
|
106
|
+
events.push({
|
|
107
|
+
idempotencyKey: `imsg:${row.ROWID}`,
|
|
108
|
+
workspaceId: "imessage",
|
|
109
|
+
channelId: handle,
|
|
110
|
+
userId: handle,
|
|
111
|
+
ts: String(row.date),
|
|
112
|
+
text,
|
|
113
|
+
subtype: "message",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (maxId !== cursor)
|
|
117
|
+
writeCursor(cursorPath, maxId);
|
|
118
|
+
return Promise.resolve({ events, cursor: maxId });
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
db.close();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
getCursor() {
|
|
125
|
+
return readCursor(cursorPath);
|
|
126
|
+
},
|
|
127
|
+
resetCursor() {
|
|
128
|
+
writeCursor(cursorPath, 0);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ─── chat.db path validation ────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Allow the canonical chat.db location (`<HOME>/Library/Messages/chat.db`)
|
|
135
|
+
* and any custom path that resolves to a file named `chat.db`. Reject
|
|
136
|
+
* absolute paths attempting traversal outside `~/Library/Messages/` UNLESS
|
|
137
|
+
* the caller explicitly overrode (tests use a tmpdir).
|
|
138
|
+
*/
|
|
139
|
+
function validateChatDbPath(path) {
|
|
140
|
+
if (!path) {
|
|
141
|
+
throw new IMessageAdapterError("chat.db path is empty (HOME env not set?)");
|
|
142
|
+
}
|
|
143
|
+
const norm = normalize(path);
|
|
144
|
+
if (!norm.endsWith("chat.db")) {
|
|
145
|
+
throw new IMessageAdapterError(`chat.db path must end in 'chat.db': ${path}`);
|
|
146
|
+
}
|
|
147
|
+
return norm;
|
|
148
|
+
}
|
|
149
|
+
function isSafeHandle(handle) {
|
|
150
|
+
// Allow email-shaped handles + +<digits> phone handles + tel: URIs.
|
|
151
|
+
return (/^[\w._%+-]+@[\w.-]+\.\w{2,}$/.test(handle) ||
|
|
152
|
+
/^\+\d{6,15}$/.test(handle) ||
|
|
153
|
+
/^tel:\+?\d{6,15}$/.test(handle));
|
|
154
|
+
}
|
|
155
|
+
function escapeAppleScriptString(s) {
|
|
156
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
157
|
+
}
|
|
158
|
+
// ─── cursor persistence ────────────────────────────────────────────────────
|
|
159
|
+
function readCursor(cursorPath) {
|
|
160
|
+
if (!existsSync(cursorPath))
|
|
161
|
+
return 0;
|
|
162
|
+
try {
|
|
163
|
+
const raw = readFileSync(cursorPath, "utf8");
|
|
164
|
+
const v = JSON.parse(raw);
|
|
165
|
+
return typeof v.cursor === "number" && Number.isFinite(v.cursor) ? v.cursor : 0;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function writeCursor(cursorPath, cursor) {
|
|
172
|
+
mkdirSync(dirname(cursorPath), { recursive: true });
|
|
173
|
+
writeFileSync(cursorPath, JSON.stringify({ cursor }), { mode: 0o600 });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Run `osascript` reading the script from stdin, using an injected `spawn`.
|
|
177
|
+
* Resolves on exit code 0; rejects with an {@link IMessageAdapterError} on a
|
|
178
|
+
* spawn failure or any non-zero exit (surfacing captured stderr).
|
|
179
|
+
*/
|
|
180
|
+
export function runOsascript(spawn, script) {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const head = "osascript";
|
|
183
|
+
const child = spawn(head, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
184
|
+
const errBufs = [];
|
|
185
|
+
child.stderr?.on("data", (b) => errBufs.push(b));
|
|
186
|
+
child.on("error", (e) => reject(new IMessageAdapterError("osascript spawn failed", e)));
|
|
187
|
+
child.on("close", (code) => {
|
|
188
|
+
if (code === 0)
|
|
189
|
+
resolve();
|
|
190
|
+
else
|
|
191
|
+
reject(new IMessageAdapterError(`osascript exited with ${code}: ${Buffer.concat(errBufs).toString("utf8")}`));
|
|
192
|
+
});
|
|
193
|
+
child.stdin?.write(script);
|
|
194
|
+
child.stdin?.end();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const defaultOsascript = async (script) => {
|
|
198
|
+
const { spawn } = await import("node:child_process");
|
|
199
|
+
return runOsascript(spawn, script);
|
|
200
|
+
};
|
|
201
|
+
// ─── chat.db row shape + query ──────────────────────────────────────────────
|
|
202
|
+
const MESSAGE_QUERY = `
|
|
203
|
+
SELECT
|
|
204
|
+
m.ROWID,
|
|
205
|
+
m.text,
|
|
206
|
+
m.is_from_me,
|
|
207
|
+
m.date,
|
|
208
|
+
m.handle_id,
|
|
209
|
+
h.id AS handle_id_str
|
|
210
|
+
FROM message m
|
|
211
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
212
|
+
WHERE m.ROWID > ?
|
|
213
|
+
ORDER BY m.ROWID ASC
|
|
214
|
+
`;
|
|
215
|
+
// ─── helpers consumed by tests ────────────────────────────────────────────
|
|
216
|
+
export const _internal = {
|
|
217
|
+
isSafeHandle,
|
|
218
|
+
escapeAppleScriptString,
|
|
219
|
+
validateChatDbPath,
|
|
220
|
+
runOsascript,
|
|
221
|
+
};
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-imessage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "macOS-only iMessage channel adapter: ~/Library/Messages/chat.db polling + osascript send + cursor-based idempotency (Section 33)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test helper — build a fixture chat.db with the minimum schema and
|
|
3
|
-
* representative rows. Returns the path to the temp .db file.
|
|
4
|
-
*/
|
|
5
|
-
import { Database } from "bun:sqlite";
|
|
6
|
-
import { mkdtempSync } from "node:fs";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
|
|
10
|
-
export function buildFixtureChatDb(): string {
|
|
11
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-imessage-fix-"));
|
|
12
|
-
const path = join(dir, "chat.db");
|
|
13
|
-
const db = new Database(path);
|
|
14
|
-
db.run(`
|
|
15
|
-
CREATE TABLE handle (
|
|
16
|
-
ROWID INTEGER PRIMARY KEY,
|
|
17
|
-
id TEXT NOT NULL,
|
|
18
|
-
service TEXT NOT NULL DEFAULT 'iMessage'
|
|
19
|
-
);
|
|
20
|
-
`);
|
|
21
|
-
db.run(`
|
|
22
|
-
CREATE TABLE message (
|
|
23
|
-
ROWID INTEGER PRIMARY KEY,
|
|
24
|
-
text TEXT,
|
|
25
|
-
is_from_me INTEGER DEFAULT 0,
|
|
26
|
-
date INTEGER DEFAULT 0,
|
|
27
|
-
handle_id INTEGER REFERENCES handle(ROWID)
|
|
28
|
-
);
|
|
29
|
-
`);
|
|
30
|
-
|
|
31
|
-
db.run("INSERT INTO handle (ROWID, id) VALUES (1, 'alice@example.com')");
|
|
32
|
-
db.run("INSERT INTO handle (ROWID, id) VALUES (2, '+15551234567')");
|
|
33
|
-
|
|
34
|
-
db.run(
|
|
35
|
-
"INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (1, 'first inbound from alice', 0, 700000000000000000, 1)",
|
|
36
|
-
);
|
|
37
|
-
db.run(
|
|
38
|
-
"INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (2, 'me replying', 1, 700000001000000000, 1)",
|
|
39
|
-
);
|
|
40
|
-
db.run(
|
|
41
|
-
"INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (3, 'inbound from phone handle', 0, 700000002000000000, 2)",
|
|
42
|
-
);
|
|
43
|
-
db.run(
|
|
44
|
-
"INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (4, '', 0, 700000003000000000, 1)",
|
|
45
|
-
);
|
|
46
|
-
db.run(
|
|
47
|
-
"INSERT INTO message (ROWID, text, is_from_me, date, handle_id) VALUES (5, 'another inbound', 0, 700000004000000000, 1)",
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
db.close();
|
|
51
|
-
return path;
|
|
52
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Coverage for the production `defaultOsascript` path — the branch that
|
|
3
|
-
* dynamically `import("node:child_process")` and spawns the real `osascript`.
|
|
4
|
-
*
|
|
5
|
-
* Calling `sendReply` without an injected `osascript` runner exercises that
|
|
6
|
-
* default. To keep the test deterministic (no real process, no real iMessage
|
|
7
|
-
* send) we replace `node:child_process` with a fake `spawn` via `mock.module`.
|
|
8
|
-
*
|
|
9
|
-
* This lives in its own file so only these tests see the mock — but Bun does
|
|
10
|
-
* NOT reset module mocks at the file boundary: `bun test` runs every file in
|
|
11
|
-
* one process, file order is nondeterministic, and a `mock.module` persists
|
|
12
|
-
* until overwritten. The `afterAll` below re-mocks the real module so the
|
|
13
|
-
* fake `spawn` cannot leak into files that happen to run later.
|
|
14
|
-
*/
|
|
15
|
-
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
16
|
-
import { EventEmitter } from "node:events";
|
|
17
|
-
import { rmSync } from "node:fs";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
19
|
-
|
|
20
|
-
import { buildFixtureChatDb } from "./fixtures/build-chat-db";
|
|
21
|
-
|
|
22
|
-
// Captured BEFORE the mock below so afterAll can reinstall the real module.
|
|
23
|
-
const realChildProcess = require("node:child_process") as typeof import("node:child_process");
|
|
24
|
-
|
|
25
|
-
/** Records the scripts handed to the fake `osascript` across the file. */
|
|
26
|
-
const writtenScripts: string[] = [];
|
|
27
|
-
let nextExit = 0;
|
|
28
|
-
|
|
29
|
-
mock.module("node:child_process", () => ({
|
|
30
|
-
spawn: (command: string, args: readonly string[], options: unknown) => {
|
|
31
|
-
// Sanity-check the default runner invokes osascript reading from stdin.
|
|
32
|
-
expect(command).toBe("osascript");
|
|
33
|
-
expect(args).toEqual(["-"]);
|
|
34
|
-
expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
|
|
35
|
-
const child = new EventEmitter() as EventEmitter & {
|
|
36
|
-
stderr: EventEmitter;
|
|
37
|
-
stdin: { write(s: string): void; end(): void };
|
|
38
|
-
};
|
|
39
|
-
child.stderr = new EventEmitter();
|
|
40
|
-
child.stdin = {
|
|
41
|
-
write: (s: string) => writtenScripts.push(s),
|
|
42
|
-
end: () => {},
|
|
43
|
-
};
|
|
44
|
-
queueMicrotask(() => {
|
|
45
|
-
if (nextExit === 0) {
|
|
46
|
-
child.emit("close", 0);
|
|
47
|
-
} else {
|
|
48
|
-
child.stderr.emit("data", Buffer.from("osascript boom", "utf8"));
|
|
49
|
-
child.emit("close", nextExit);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
return child;
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
afterAll(() => {
|
|
57
|
-
// Bun has no mock.module restore API; re-mocking with the real exports is
|
|
58
|
-
// the documented way to undo a module mock. Without this, the fake `spawn`
|
|
59
|
-
// (which asserts it is called as `osascript`) stays registered for every
|
|
60
|
-
// test file that runs after this one.
|
|
61
|
-
mock.module("node:child_process", () => realChildProcess);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Import AFTER registering the mock so the dynamic import inside
|
|
65
|
-
// defaultOsascript resolves to the fake.
|
|
66
|
-
const { createIMessageAdapter } = await import("./index");
|
|
67
|
-
|
|
68
|
-
function makeAdapter() {
|
|
69
|
-
const dbPath = buildFixtureChatDb();
|
|
70
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
71
|
-
const adapter = createIMessageAdapter({
|
|
72
|
-
chatDbPath: dbPath,
|
|
73
|
-
cursorPath,
|
|
74
|
-
requireHostOptIn: false,
|
|
75
|
-
});
|
|
76
|
-
return { adapter, cleanup: () => rmSync(dirname(dbPath), { recursive: true, force: true }) };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const baseEvent = {
|
|
80
|
-
idempotencyKey: "imsg:1",
|
|
81
|
-
workspaceId: "imessage",
|
|
82
|
-
channelId: "alice@example.com",
|
|
83
|
-
userId: "alice@example.com",
|
|
84
|
-
ts: "0",
|
|
85
|
-
text: "in",
|
|
86
|
-
subtype: "message",
|
|
87
|
-
} as const;
|
|
88
|
-
|
|
89
|
-
describe("defaultOsascript (real-runner branch, child_process mocked)", () => {
|
|
90
|
-
test("sendReply with no injected runner spawns osascript and writes the script", async () => {
|
|
91
|
-
nextExit = 0;
|
|
92
|
-
writtenScripts.length = 0;
|
|
93
|
-
const { adapter, cleanup } = makeAdapter();
|
|
94
|
-
try {
|
|
95
|
-
await adapter.sendReply({ event: baseEvent, text: "hello from default" });
|
|
96
|
-
expect(writtenScripts.length).toBe(1);
|
|
97
|
-
expect(writtenScripts[0]).toContain('tell application "Messages"');
|
|
98
|
-
expect(writtenScripts[0]).toContain('send "hello from default"');
|
|
99
|
-
} finally {
|
|
100
|
-
cleanup();
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("sendReply rejects when the spawned osascript exits non-zero", async () => {
|
|
105
|
-
nextExit = 3;
|
|
106
|
-
writtenScripts.length = 0;
|
|
107
|
-
const { adapter, cleanup } = makeAdapter();
|
|
108
|
-
try {
|
|
109
|
-
await expect(adapter.sendReply({ event: baseEvent, text: "x" })).rejects.toThrow(
|
|
110
|
-
/osascript exited with 3: osascript boom/,
|
|
111
|
-
);
|
|
112
|
-
} finally {
|
|
113
|
-
cleanup();
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
});
|
package/src/index.test.ts
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import { buildFixtureChatDb } from "./fixtures/build-chat-db";
|
|
7
|
-
import {
|
|
8
|
-
IMessageAdapterError,
|
|
9
|
-
type OsascriptSpawn,
|
|
10
|
-
_internal,
|
|
11
|
-
createIMessageAdapter,
|
|
12
|
-
runOsascript,
|
|
13
|
-
} from "./index";
|
|
14
|
-
|
|
15
|
-
const { isSafeHandle, escapeAppleScriptString, validateChatDbPath } = _internal;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Build a deterministic fake of `child_process.spawn` for {@link runOsascript}
|
|
19
|
-
* tests — no real process. `behavior` decides which lifecycle events fire and
|
|
20
|
-
* is invoked on a microtask so the runner has wired its listeners first.
|
|
21
|
-
*/
|
|
22
|
-
function fakeSpawn(behavior: {
|
|
23
|
-
readonly stderrChunks?: readonly string[];
|
|
24
|
-
readonly closeCode?: number | null;
|
|
25
|
-
readonly emitError?: Error;
|
|
26
|
-
/** Force `stdin`/`stderr` to be null to exercise the optional-chaining guards. */
|
|
27
|
-
readonly nullStreams?: boolean;
|
|
28
|
-
}): { spawn: OsascriptSpawn; getWritten: () => string | undefined; ended: () => boolean } {
|
|
29
|
-
let written: string | undefined;
|
|
30
|
-
let didEnd = false;
|
|
31
|
-
const spawn: OsascriptSpawn = (command, args, options) => {
|
|
32
|
-
// The runner must always invoke osascript reading the script from stdin.
|
|
33
|
-
expect(command).toBe("osascript");
|
|
34
|
-
expect(args).toEqual(["-"]);
|
|
35
|
-
expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
|
|
36
|
-
const handlers: Record<string, (arg: never) => void> = {};
|
|
37
|
-
const stderrHandlers: ((chunk: Buffer) => void)[] = [];
|
|
38
|
-
queueMicrotask(() => {
|
|
39
|
-
for (const c of behavior.stderrChunks ?? []) {
|
|
40
|
-
for (const h of stderrHandlers) h(Buffer.from(c, "utf8"));
|
|
41
|
-
}
|
|
42
|
-
if (behavior.emitError) handlers["error"]?.(behavior.emitError as never);
|
|
43
|
-
else handlers["close"]?.((behavior.closeCode ?? 0) as never);
|
|
44
|
-
});
|
|
45
|
-
return {
|
|
46
|
-
stderr: behavior.nullStreams ? null : { on: (_e, cb) => stderrHandlers.push(cb) },
|
|
47
|
-
stdin: behavior.nullStreams
|
|
48
|
-
? null
|
|
49
|
-
: {
|
|
50
|
-
write: (chunk: string) => {
|
|
51
|
-
written = chunk;
|
|
52
|
-
},
|
|
53
|
-
end: () => {
|
|
54
|
-
didEnd = true;
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
on: (event: string, cb: (arg: never) => void) => {
|
|
58
|
-
handlers[event] = cb;
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
};
|
|
62
|
-
return { spawn, getWritten: () => written, ended: () => didEnd };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe("runOsascript (default-runner core, dependency-injected spawn)", () => {
|
|
66
|
-
test("writes the script to stdin and resolves on exit code 0", async () => {
|
|
67
|
-
const f = fakeSpawn({ closeCode: 0 });
|
|
68
|
-
await runOsascript(f.spawn, "tell app");
|
|
69
|
-
expect(f.getWritten()).toBe("tell app");
|
|
70
|
-
expect(f.ended()).toBe(true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("rejects with stderr text + code on a non-zero exit", async () => {
|
|
74
|
-
const f = fakeSpawn({ closeCode: 2, stderrChunks: ["bad ", "things"] });
|
|
75
|
-
await expect(runOsascript(f.spawn, "x")).rejects.toThrow(/osascript exited with 2: bad things/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("rejects with a clear message when spawn emits 'error'", async () => {
|
|
79
|
-
const boom = new Error("ENOENT osascript");
|
|
80
|
-
const f = fakeSpawn({ emitError: boom });
|
|
81
|
-
let caught: unknown;
|
|
82
|
-
try {
|
|
83
|
-
await runOsascript(f.spawn, "x");
|
|
84
|
-
} catch (e) {
|
|
85
|
-
caught = e;
|
|
86
|
-
}
|
|
87
|
-
expect(caught).toBeInstanceOf(IMessageAdapterError);
|
|
88
|
-
expect((caught as Error).message).toBe("osascript spawn failed");
|
|
89
|
-
// The originating error is preserved on the cause chain.
|
|
90
|
-
expect((caught as IMessageAdapterError).cause).toBe(boom);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("null stdin/stderr streams are tolerated (optional-chaining guards)", async () => {
|
|
94
|
-
const f = fakeSpawn({ nullStreams: true, closeCode: 0 });
|
|
95
|
-
await runOsascript(f.spawn, "x");
|
|
96
|
-
// Nothing was written because stdin was null; the runner still resolved.
|
|
97
|
-
expect(f.getWritten()).toBeUndefined();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("exposed on _internal for the adapter's wiring", () => {
|
|
101
|
-
expect(_internal.runOsascript).toBe(runOsascript);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("isSafeHandle / escapeAppleScriptString / validateChatDbPath", () => {
|
|
106
|
-
test("accepts well-formed iMessage handles", () => {
|
|
107
|
-
expect(isSafeHandle("alice@example.com")).toBe(true);
|
|
108
|
-
expect(isSafeHandle("+15551234567")).toBe(true);
|
|
109
|
-
expect(isSafeHandle("tel:+15551234567")).toBe(true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("rejects shell-injection-shaped handles", () => {
|
|
113
|
-
expect(isSafeHandle('alice@example.com";rm -rf /')).toBe(false);
|
|
114
|
-
expect(isSafeHandle("$(curl evil.com)")).toBe(false);
|
|
115
|
-
expect(isSafeHandle("../../../etc/passwd")).toBe(false);
|
|
116
|
-
expect(isSafeHandle("")).toBe(false);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("escapes AppleScript metacharacters", () => {
|
|
120
|
-
expect(escapeAppleScriptString('hi "you"')).toBe('hi \\"you\\"');
|
|
121
|
-
expect(escapeAppleScriptString("path\\to\\file")).toBe("path\\\\to\\\\file");
|
|
122
|
-
expect(escapeAppleScriptString("line1\nline2")).toBe("line1\\nline2");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("validateChatDbPath requires chat.db filename and rejects empty", () => {
|
|
126
|
-
expect(() => validateChatDbPath("")).toThrow(/chat.db path is empty/);
|
|
127
|
-
expect(() => validateChatDbPath("/etc/passwd")).toThrow(/must end in 'chat.db'/);
|
|
128
|
-
expect(validateChatDbPath("/tmp/some/chat.db")).toBe("/tmp/some/chat.db");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("createIMessageAdapter — opt-in guard (T8)", () => {
|
|
133
|
-
test("requireHostOptIn=false bypasses guards (test mode)", () => {
|
|
134
|
-
const dbPath = buildFixtureChatDb();
|
|
135
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
136
|
-
const a = createIMessageAdapter({
|
|
137
|
-
chatDbPath: dbPath,
|
|
138
|
-
cursorPath,
|
|
139
|
-
requireHostOptIn: false,
|
|
140
|
-
});
|
|
141
|
-
expect(a.id).toBe("imessage");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("missing chat.db throws clear error", () => {
|
|
145
|
-
expect(() =>
|
|
146
|
-
createIMessageAdapter({
|
|
147
|
-
chatDbPath: join(tmpdir(), "definitely-missing", "chat.db"),
|
|
148
|
-
requireHostOptIn: false,
|
|
149
|
-
}),
|
|
150
|
-
).toThrow(/chat.db not found/);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe("pollNewMessages (T2)", () => {
|
|
155
|
-
let dbPath: string;
|
|
156
|
-
let cursorPath: string;
|
|
157
|
-
let adapter: ReturnType<typeof createIMessageAdapter>;
|
|
158
|
-
|
|
159
|
-
beforeAll(() => {
|
|
160
|
-
dbPath = buildFixtureChatDb();
|
|
161
|
-
cursorPath = join(dirname(dbPath), "cursor.json");
|
|
162
|
-
adapter = createIMessageAdapter({
|
|
163
|
-
chatDbPath: dbPath,
|
|
164
|
-
cursorPath,
|
|
165
|
-
requireHostOptIn: false,
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
afterAll(() => {
|
|
170
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("first poll returns 3 inbound (skips me + empty), advances cursor to ROWID 5", async () => {
|
|
174
|
-
const r = await adapter.pollNewMessages();
|
|
175
|
-
expect(r.events.length).toBe(3);
|
|
176
|
-
expect(r.cursor).toBe(5);
|
|
177
|
-
expect(r.events[0]?.idempotencyKey).toBe("imsg:1");
|
|
178
|
-
expect(r.events[0]?.text).toBe("first inbound from alice");
|
|
179
|
-
expect(r.events[0]?.userId).toBe("alice@example.com");
|
|
180
|
-
expect(r.events[0]?.channelId).toBe("alice@example.com");
|
|
181
|
-
expect(r.events[1]?.idempotencyKey).toBe("imsg:3");
|
|
182
|
-
expect(r.events[1]?.userId).toBe("+15551234567");
|
|
183
|
-
expect(r.events[2]?.idempotencyKey).toBe("imsg:5");
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("subsequent poll with no new rows returns empty + same cursor", async () => {
|
|
187
|
-
const first = await adapter.pollNewMessages();
|
|
188
|
-
const second = await adapter.pollNewMessages();
|
|
189
|
-
expect(second.events.length).toBe(0);
|
|
190
|
-
expect(second.cursor).toBe(first.cursor);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("cursor persists across new adapter instances (idempotency across restart)", async () => {
|
|
194
|
-
// First adapter polls and advances cursor.
|
|
195
|
-
await adapter.pollNewMessages();
|
|
196
|
-
// Construct a fresh adapter pointing at the same cursor file.
|
|
197
|
-
const a2 = createIMessageAdapter({
|
|
198
|
-
chatDbPath: dbPath,
|
|
199
|
-
cursorPath,
|
|
200
|
-
requireHostOptIn: false,
|
|
201
|
-
});
|
|
202
|
-
const r = await a2.pollNewMessages();
|
|
203
|
-
expect(r.events.length).toBe(0);
|
|
204
|
-
expect(a2.getCursor()).toBe(5);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("resetCursor clears the persisted state", async () => {
|
|
208
|
-
adapter.resetCursor();
|
|
209
|
-
expect(adapter.getCursor()).toBe(0);
|
|
210
|
-
const r = await adapter.pollNewMessages();
|
|
211
|
-
expect(r.events.length).toBe(3);
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("sendReply (T3)", () => {
|
|
216
|
-
test("escapes message text + handle, hits osascript with the right script", async () => {
|
|
217
|
-
const dbPath = buildFixtureChatDb();
|
|
218
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
219
|
-
const calls: string[] = [];
|
|
220
|
-
const a = createIMessageAdapter(
|
|
221
|
-
{
|
|
222
|
-
chatDbPath: dbPath,
|
|
223
|
-
cursorPath,
|
|
224
|
-
requireHostOptIn: false,
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
osascript: async (script) => {
|
|
228
|
-
calls.push(script);
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
);
|
|
232
|
-
await a.sendReply({
|
|
233
|
-
event: {
|
|
234
|
-
idempotencyKey: "imsg:1",
|
|
235
|
-
workspaceId: "imessage",
|
|
236
|
-
channelId: "alice@example.com",
|
|
237
|
-
userId: "alice@example.com",
|
|
238
|
-
ts: "0",
|
|
239
|
-
text: "hello",
|
|
240
|
-
subtype: "message",
|
|
241
|
-
},
|
|
242
|
-
text: 'reply with "quotes"',
|
|
243
|
-
});
|
|
244
|
-
expect(calls.length).toBe(1);
|
|
245
|
-
const script = calls[0] ?? "";
|
|
246
|
-
expect(script).toContain('tell application "Messages"');
|
|
247
|
-
expect(script).toContain('buddy "alice@example.com"');
|
|
248
|
-
expect(script).toContain('send "reply with \\"quotes\\""');
|
|
249
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("rejects shell-injection in handle (path-traversal / metachar guard)", async () => {
|
|
253
|
-
const dbPath = buildFixtureChatDb();
|
|
254
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
255
|
-
const a = createIMessageAdapter(
|
|
256
|
-
{ chatDbPath: dbPath, cursorPath, requireHostOptIn: false },
|
|
257
|
-
{ osascript: async () => undefined },
|
|
258
|
-
);
|
|
259
|
-
await expect(
|
|
260
|
-
a.sendReply({
|
|
261
|
-
event: {
|
|
262
|
-
idempotencyKey: "x",
|
|
263
|
-
workspaceId: "imessage",
|
|
264
|
-
channelId: 'alice"; do bad`',
|
|
265
|
-
userId: 'alice"; do bad`',
|
|
266
|
-
ts: "0",
|
|
267
|
-
text: "x",
|
|
268
|
-
subtype: "message",
|
|
269
|
-
},
|
|
270
|
-
text: "x",
|
|
271
|
-
}),
|
|
272
|
-
).rejects.toThrow(/unsafe iMessage handle/);
|
|
273
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe("setTyping is a no-op", () => {
|
|
278
|
-
test("does not throw or call osascript", async () => {
|
|
279
|
-
const dbPath = buildFixtureChatDb();
|
|
280
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
281
|
-
let called = false;
|
|
282
|
-
const a = createIMessageAdapter(
|
|
283
|
-
{ chatDbPath: dbPath, cursorPath, requireHostOptIn: false },
|
|
284
|
-
{
|
|
285
|
-
osascript: async () => {
|
|
286
|
-
called = true;
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
);
|
|
290
|
-
await a.setTyping({
|
|
291
|
-
event: {
|
|
292
|
-
idempotencyKey: "x",
|
|
293
|
-
workspaceId: "imessage",
|
|
294
|
-
channelId: "alice@example.com",
|
|
295
|
-
userId: "alice@example.com",
|
|
296
|
-
ts: "0",
|
|
297
|
-
text: "",
|
|
298
|
-
subtype: "message",
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
expect(called).toBe(false);
|
|
302
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
describe("verify / parseInbound (no-op for poll-driven adapter)", () => {
|
|
307
|
-
test("verify returns true; parseInbound returns skip", () => {
|
|
308
|
-
const dbPath = buildFixtureChatDb();
|
|
309
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
310
|
-
const a = createIMessageAdapter({
|
|
311
|
-
chatDbPath: dbPath,
|
|
312
|
-
cursorPath,
|
|
313
|
-
requireHostOptIn: false,
|
|
314
|
-
});
|
|
315
|
-
expect(a.verify({ headers: new Headers(), body: "" })).toBe(true);
|
|
316
|
-
expect(a.parseInbound({ headers: new Headers(), body: "{}" }).kind).toBe("skip");
|
|
317
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
describe("cursor file integrity", () => {
|
|
322
|
-
test("malformed cursor file falls back to 0", () => {
|
|
323
|
-
const dbPath = buildFixtureChatDb();
|
|
324
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
325
|
-
writeFileSync(cursorPath, "not json{");
|
|
326
|
-
const a = createIMessageAdapter({
|
|
327
|
-
chatDbPath: dbPath,
|
|
328
|
-
cursorPath,
|
|
329
|
-
requireHostOptIn: false,
|
|
330
|
-
});
|
|
331
|
-
expect(a.getCursor()).toBe(0);
|
|
332
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
test("cursor file written with 0o600 mode", async () => {
|
|
336
|
-
const dbPath = buildFixtureChatDb();
|
|
337
|
-
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
338
|
-
const a = createIMessageAdapter({
|
|
339
|
-
chatDbPath: dbPath,
|
|
340
|
-
cursorPath,
|
|
341
|
-
requireHostOptIn: false,
|
|
342
|
-
});
|
|
343
|
-
await a.pollNewMessages();
|
|
344
|
-
if (existsSync(cursorPath)) {
|
|
345
|
-
const { statSync } = await import("node:fs");
|
|
346
|
-
const mode = statSync(cursorPath).mode & 0o777;
|
|
347
|
-
// On Bun/Linux the umask may downgrade — accept anything <= 0o600.
|
|
348
|
-
expect(mode <= 0o600).toBe(true);
|
|
349
|
-
}
|
|
350
|
-
expect(JSON.parse(readFileSync(cursorPath, "utf8"))).toEqual({ cursor: 5 });
|
|
351
|
-
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
352
|
-
});
|
|
353
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, normalize } from "node:path";
|
|
4
|
-
/**
|
|
5
|
-
* @crewhaus/channel-adapter-imessage — macOS-only iMessage channel
|
|
6
|
-
* adapter for the channel target (Section 33).
|
|
7
|
-
*
|
|
8
|
-
* Apple does NOT publish a public iMessage Business API for general
|
|
9
|
-
* agent integrations. This adapter takes a host-bound approach:
|
|
10
|
-
*
|
|
11
|
-
* - Inbound: poll Messages.app's SQLite store at
|
|
12
|
-
* ~/Library/Messages/chat.db for new rows since the last cursor.
|
|
13
|
-
* The cursor is `message.ROWID` — monotonically increasing per
|
|
14
|
-
* install. We persist it to a small JSON file so a daemon restart
|
|
15
|
-
* resumes from where it left off.
|
|
16
|
-
*
|
|
17
|
-
* - Outbound: drive Messages.app via osascript. The handle (an
|
|
18
|
-
* iMessage email/phone) is the conversation key.
|
|
19
|
-
*
|
|
20
|
-
* Hard requirements (enforced via guard / startup check):
|
|
21
|
-
* - Process is running on macOS (`process.platform === "darwin"`).
|
|
22
|
-
* - `CREWHAUS_IMESSAGE_HOST_ENABLED=1` is set — opt-in to using the
|
|
23
|
-
* host's logged-in iMessage account.
|
|
24
|
-
* - Full Disk Access permission is granted to the daemon's host
|
|
25
|
-
* terminal/process so chat.db is readable. (We surface a clear
|
|
26
|
-
* error if the file isn't reachable.)
|
|
27
|
-
*
|
|
28
|
-
* Path-traversal safety: chat.db's path is constructed from the HOME
|
|
29
|
-
* env at boot and validated against a fixed prefix
|
|
30
|
-
* (`~/Library/Messages/`). Custom override is allowed only via the
|
|
31
|
-
* adapter's `chatDbPath` option (intended for tests with fixture DBs)
|
|
32
|
-
* AND must resolve to a file with name `chat.db`.
|
|
33
|
-
*/
|
|
34
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
35
|
-
|
|
36
|
-
export class IMessageAdapterError extends CrewhausError {
|
|
37
|
-
override readonly name = "IMessageAdapterError";
|
|
38
|
-
constructor(message: string, cause?: unknown) {
|
|
39
|
-
super("channel", message, cause);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type RawRequest = {
|
|
44
|
-
readonly headers: Headers;
|
|
45
|
-
readonly body: string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/** Channel-generic inbound event — same shape as Slack/Telegram/etc. */
|
|
49
|
-
export type InboundEvent = {
|
|
50
|
-
readonly idempotencyKey: string;
|
|
51
|
-
readonly workspaceId: string;
|
|
52
|
-
readonly channelId: string;
|
|
53
|
-
readonly userId: string;
|
|
54
|
-
readonly threadTs?: string;
|
|
55
|
-
readonly ts: string;
|
|
56
|
-
readonly text: string;
|
|
57
|
-
readonly subtype: "app_mention" | "message";
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export type ParsedInbound =
|
|
61
|
-
| { readonly kind: "event"; readonly event: InboundEvent }
|
|
62
|
-
| { readonly kind: "skip" };
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The iMessage adapter is non-webhook — there's no inbound HTTP. Polling
|
|
66
|
-
* happens via `pollNewMessages()` which the daemon harness calls on a
|
|
67
|
-
* schedule. We still implement the same `ChannelAdapter` interface so
|
|
68
|
-
* the §33 multi-adapter wiring can register it; `verify` and
|
|
69
|
-
* `parseInbound` are never called by the gateway for iMessage (the
|
|
70
|
-
* polling loop emits InboundEvents directly).
|
|
71
|
-
*/
|
|
72
|
-
export interface ChannelAdapter {
|
|
73
|
-
readonly id: string;
|
|
74
|
-
verify(req: RawRequest): boolean;
|
|
75
|
-
parseInbound(req: RawRequest): ParsedInbound;
|
|
76
|
-
sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
|
|
77
|
-
setTyping(args: { event: InboundEvent }): Promise<void>;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface IMessageAdapter extends ChannelAdapter {
|
|
81
|
-
/**
|
|
82
|
-
* Poll chat.db for messages with ROWID > cursor. Returns the new
|
|
83
|
-
* messages and the new cursor. Idempotent across restarts: reads
|
|
84
|
-
* the persisted cursor from `cursorPath` if present.
|
|
85
|
-
*/
|
|
86
|
-
pollNewMessages(): Promise<{
|
|
87
|
-
readonly events: readonly InboundEvent[];
|
|
88
|
-
readonly cursor: number;
|
|
89
|
-
}>;
|
|
90
|
-
/** Returns the current persisted cursor (or 0 if not yet set). */
|
|
91
|
-
getCursor(): number;
|
|
92
|
-
/** Reset the cursor — used by tests + the `--reset-cursor` CLI flag. */
|
|
93
|
-
resetCursor(): void;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export type IMessageAdapterConfig = {
|
|
97
|
-
/**
|
|
98
|
-
* Custom chat.db path. Defaults to `<HOME>/Library/Messages/chat.db`.
|
|
99
|
-
* Overridable for tests (must end in `chat.db`).
|
|
100
|
-
*/
|
|
101
|
-
readonly chatDbPath?: string;
|
|
102
|
-
/** Where to persist the polling cursor. Defaults to `.crewhaus/imessage-cursor.json`. */
|
|
103
|
-
readonly cursorPath?: string;
|
|
104
|
-
/**
|
|
105
|
-
* Required: gate the adapter on opt-in env. Setting this to false
|
|
106
|
-
* (e.g. in tests with fixture DBs) bypasses the
|
|
107
|
-
* `CREWHAUS_IMESSAGE_HOST_ENABLED=1` env-check. Defaults to true.
|
|
108
|
-
*/
|
|
109
|
-
readonly requireHostOptIn?: boolean;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export type IMessageAdapterOptions = {
|
|
113
|
-
/** Inject a custom osascript runner (defaults to spawning `osascript`). */
|
|
114
|
-
readonly osascript?: (script: string) => Promise<void>;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const CHAT_DB_REL = "Library/Messages/chat.db";
|
|
118
|
-
|
|
119
|
-
export function createIMessageAdapter(
|
|
120
|
-
config: IMessageAdapterConfig = {},
|
|
121
|
-
opts: IMessageAdapterOptions = {},
|
|
122
|
-
): IMessageAdapter {
|
|
123
|
-
const requireHostOptIn = config.requireHostOptIn ?? true;
|
|
124
|
-
if (requireHostOptIn) {
|
|
125
|
-
if (process.platform !== "darwin") {
|
|
126
|
-
throw new IMessageAdapterError(
|
|
127
|
-
`iMessage adapter requires macOS (process.platform=${process.platform})`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
if (process.env["CREWHAUS_IMESSAGE_HOST_ENABLED"] !== "1") {
|
|
131
|
-
throw new IMessageAdapterError(
|
|
132
|
-
"iMessage adapter requires CREWHAUS_IMESSAGE_HOST_ENABLED=1 (opt-in to host's logged-in iMessage)",
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const home = process.env["HOME"] ?? "";
|
|
138
|
-
const defaultDbPath = home ? `${home}/${CHAT_DB_REL}` : "";
|
|
139
|
-
const chatDbPath = validateChatDbPath(config.chatDbPath ?? defaultDbPath);
|
|
140
|
-
if (!existsSync(chatDbPath)) {
|
|
141
|
-
throw new IMessageAdapterError(
|
|
142
|
-
`iMessage chat.db not found at ${chatDbPath} (full disk access?)`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const cursorPath = config.cursorPath ?? ".crewhaus/imessage-cursor.json";
|
|
147
|
-
|
|
148
|
-
const osascriptRun = opts.osascript ?? defaultOsascript;
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
id: "imessage",
|
|
152
|
-
|
|
153
|
-
verify(_req: RawRequest): boolean {
|
|
154
|
-
// No inbound HTTP — iMessage is polling-driven. We accept all by
|
|
155
|
-
// returning true so the gateway path never short-circuits, but
|
|
156
|
-
// the gateway ought to never call us anyway.
|
|
157
|
-
return true;
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
parseInbound(_req: RawRequest): ParsedInbound {
|
|
161
|
-
return { kind: "skip" };
|
|
162
|
-
},
|
|
163
|
-
|
|
164
|
-
async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
|
|
165
|
-
const handle = args.event.userId;
|
|
166
|
-
if (!isSafeHandle(handle)) {
|
|
167
|
-
throw new IMessageAdapterError(`unsafe iMessage handle: ${handle}`);
|
|
168
|
-
}
|
|
169
|
-
const escapedText = escapeAppleScriptString(args.text);
|
|
170
|
-
const escapedHandle = escapeAppleScriptString(handle);
|
|
171
|
-
const script = [
|
|
172
|
-
'tell application "Messages"',
|
|
173
|
-
" set targetService to 1st service whose service type = iMessage",
|
|
174
|
-
` set targetBuddy to buddy "${escapedHandle}" of targetService`,
|
|
175
|
-
` send "${escapedText}" to targetBuddy`,
|
|
176
|
-
"end tell",
|
|
177
|
-
].join("\n");
|
|
178
|
-
await osascriptRun(script);
|
|
179
|
-
},
|
|
180
|
-
|
|
181
|
-
async setTyping(_args: { event: InboundEvent }): Promise<void> {
|
|
182
|
-
// Messages.app does not expose a typing-indicator API to AppleScript.
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
pollNewMessages(): Promise<{ events: readonly InboundEvent[]; cursor: number }> {
|
|
186
|
-
const cursor = readCursor(cursorPath);
|
|
187
|
-
const db = new Database(chatDbPath, { readonly: true });
|
|
188
|
-
try {
|
|
189
|
-
const rows = db.query<MessageRow, [number]>(MESSAGE_QUERY).all(cursor);
|
|
190
|
-
const events: InboundEvent[] = [];
|
|
191
|
-
let maxId = cursor;
|
|
192
|
-
for (const row of rows) {
|
|
193
|
-
if (row.ROWID > maxId) maxId = row.ROWID;
|
|
194
|
-
if (row.is_from_me === 1) continue;
|
|
195
|
-
const text = row.text ?? "";
|
|
196
|
-
if (text.trim() === "") continue;
|
|
197
|
-
const handle = row.handle_id_str ?? `handle:${row.handle_id ?? "unknown"}`;
|
|
198
|
-
events.push({
|
|
199
|
-
idempotencyKey: `imsg:${row.ROWID}`,
|
|
200
|
-
workspaceId: "imessage",
|
|
201
|
-
channelId: handle,
|
|
202
|
-
userId: handle,
|
|
203
|
-
ts: String(row.date),
|
|
204
|
-
text,
|
|
205
|
-
subtype: "message",
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
if (maxId !== cursor) writeCursor(cursorPath, maxId);
|
|
209
|
-
return Promise.resolve({ events, cursor: maxId });
|
|
210
|
-
} finally {
|
|
211
|
-
db.close();
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
getCursor(): number {
|
|
216
|
-
return readCursor(cursorPath);
|
|
217
|
-
},
|
|
218
|
-
|
|
219
|
-
resetCursor(): void {
|
|
220
|
-
writeCursor(cursorPath, 0);
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ─── chat.db path validation ────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Allow the canonical chat.db location (`<HOME>/Library/Messages/chat.db`)
|
|
229
|
-
* and any custom path that resolves to a file named `chat.db`. Reject
|
|
230
|
-
* absolute paths attempting traversal outside `~/Library/Messages/` UNLESS
|
|
231
|
-
* the caller explicitly overrode (tests use a tmpdir).
|
|
232
|
-
*/
|
|
233
|
-
function validateChatDbPath(path: string): string {
|
|
234
|
-
if (!path) {
|
|
235
|
-
throw new IMessageAdapterError("chat.db path is empty (HOME env not set?)");
|
|
236
|
-
}
|
|
237
|
-
const norm = normalize(path);
|
|
238
|
-
if (!norm.endsWith("chat.db")) {
|
|
239
|
-
throw new IMessageAdapterError(`chat.db path must end in 'chat.db': ${path}`);
|
|
240
|
-
}
|
|
241
|
-
return norm;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function isSafeHandle(handle: string): boolean {
|
|
245
|
-
// Allow email-shaped handles + +<digits> phone handles + tel: URIs.
|
|
246
|
-
return (
|
|
247
|
-
/^[\w._%+-]+@[\w.-]+\.\w{2,}$/.test(handle) ||
|
|
248
|
-
/^\+\d{6,15}$/.test(handle) ||
|
|
249
|
-
/^tel:\+?\d{6,15}$/.test(handle)
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function escapeAppleScriptString(s: string): string {
|
|
254
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ─── cursor persistence ────────────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
function readCursor(cursorPath: string): number {
|
|
260
|
-
if (!existsSync(cursorPath)) return 0;
|
|
261
|
-
try {
|
|
262
|
-
const raw = readFileSync(cursorPath, "utf8");
|
|
263
|
-
const v = JSON.parse(raw) as { cursor?: number };
|
|
264
|
-
return typeof v.cursor === "number" && Number.isFinite(v.cursor) ? v.cursor : 0;
|
|
265
|
-
} catch {
|
|
266
|
-
return 0;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function writeCursor(cursorPath: string, cursor: number): void {
|
|
271
|
-
mkdirSync(dirname(cursorPath), { recursive: true });
|
|
272
|
-
writeFileSync(cursorPath, JSON.stringify({ cursor }), { mode: 0o600 });
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// ─── default osascript runner ──────────────────────────────────────────────
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Minimal structural type for the slice of `child_process.spawn` that the
|
|
279
|
-
* osascript runner uses. Keeping it explicit (rather than importing Node's
|
|
280
|
-
* `ChildProcess`) lets tests inject a deterministic fake without a real
|
|
281
|
-
* process spawn — mirroring the dependency-injection style used elsewhere in
|
|
282
|
-
* factory's adapters.
|
|
283
|
-
*/
|
|
284
|
-
type SpawnedChild = {
|
|
285
|
-
readonly stdout?: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
|
|
286
|
-
readonly stderr: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
|
|
287
|
-
readonly stdin: { write(chunk: string): void; end(): void } | null;
|
|
288
|
-
on(event: "error", cb: (err: Error) => void): void;
|
|
289
|
-
on(event: "close", cb: (code: number | null) => void): void;
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
export type OsascriptSpawn = (
|
|
293
|
-
command: string,
|
|
294
|
-
args: readonly string[],
|
|
295
|
-
options: { stdio: readonly ["pipe", "ignore", "pipe"] },
|
|
296
|
-
) => SpawnedChild;
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Run `osascript` reading the script from stdin, using an injected `spawn`.
|
|
300
|
-
* Resolves on exit code 0; rejects with an {@link IMessageAdapterError} on a
|
|
301
|
-
* spawn failure or any non-zero exit (surfacing captured stderr).
|
|
302
|
-
*/
|
|
303
|
-
export function runOsascript(spawn: OsascriptSpawn, script: string): Promise<void> {
|
|
304
|
-
return new Promise<void>((resolve, reject) => {
|
|
305
|
-
const head = "osascript";
|
|
306
|
-
const child = spawn(head, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
307
|
-
const errBufs: Buffer[] = [];
|
|
308
|
-
child.stderr?.on("data", (b) => errBufs.push(b));
|
|
309
|
-
child.on("error", (e) => reject(new IMessageAdapterError("osascript spawn failed", e)));
|
|
310
|
-
child.on("close", (code) => {
|
|
311
|
-
if (code === 0) resolve();
|
|
312
|
-
else
|
|
313
|
-
reject(
|
|
314
|
-
new IMessageAdapterError(
|
|
315
|
-
`osascript exited with ${code}: ${Buffer.concat(errBufs).toString("utf8")}`,
|
|
316
|
-
),
|
|
317
|
-
);
|
|
318
|
-
});
|
|
319
|
-
child.stdin?.write(script);
|
|
320
|
-
child.stdin?.end();
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const defaultOsascript = async (script: string): Promise<void> => {
|
|
325
|
-
const { spawn } = await import("node:child_process");
|
|
326
|
-
return runOsascript(spawn as unknown as OsascriptSpawn, script);
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
// ─── chat.db row shape + query ──────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
const MESSAGE_QUERY = `
|
|
332
|
-
SELECT
|
|
333
|
-
m.ROWID,
|
|
334
|
-
m.text,
|
|
335
|
-
m.is_from_me,
|
|
336
|
-
m.date,
|
|
337
|
-
m.handle_id,
|
|
338
|
-
h.id AS handle_id_str
|
|
339
|
-
FROM message m
|
|
340
|
-
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
341
|
-
WHERE m.ROWID > ?
|
|
342
|
-
ORDER BY m.ROWID ASC
|
|
343
|
-
`;
|
|
344
|
-
|
|
345
|
-
type MessageRow = {
|
|
346
|
-
readonly ROWID: number;
|
|
347
|
-
readonly text: string | null;
|
|
348
|
-
readonly is_from_me: number;
|
|
349
|
-
readonly date: number;
|
|
350
|
-
readonly handle_id: number | null;
|
|
351
|
-
readonly handle_id_str: string | null;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// ─── helpers consumed by tests ────────────────────────────────────────────
|
|
355
|
-
|
|
356
|
-
export const _internal = {
|
|
357
|
-
isSafeHandle,
|
|
358
|
-
escapeAppleScriptString,
|
|
359
|
-
validateChatDbPath,
|
|
360
|
-
runOsascript,
|
|
361
|
-
};
|