clawty 0.0.1
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/README.md +157 -0
- package/bin/clawty.ts +2 -0
- package/bin/imessage-claude.ts +2 -0
- package/package.json +44 -0
- package/src/claude/runner.ts +15 -0
- package/src/cli.ts +1959 -0
- package/src/imessage/database.ts +427 -0
- package/src/imessage/sender.ts +196 -0
- package/src/imessage/types.ts +9 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import type { IMessage } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const CHAT_DB_PATH = join(homedir(), "Library", "Messages", "chat.db");
|
|
8
|
+
const APPLE_EPOCH_OFFSET = 978307200;
|
|
9
|
+
|
|
10
|
+
// On modern macOS, date is in nanoseconds since 2001-01-01
|
|
11
|
+
function appleTimestampToDate(timestamp: number): Date {
|
|
12
|
+
// Handle both seconds and nanosecond formats
|
|
13
|
+
// Nanosecond timestamps are > 1e15, second timestamps are < 1e12
|
|
14
|
+
const seconds = timestamp > 1e15 ? timestamp / 1e9 : timestamp;
|
|
15
|
+
return new Date((seconds + APPLE_EPOCH_OFFSET) * 1000);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Extract text from attributedBody (NSAttributedString binary plist)
|
|
19
|
+
// On newer macOS, `text` column can be NULL and content is in attributedBody
|
|
20
|
+
function extractTextFromAttributedBody(buf: Buffer | Uint8Array | null): string | null {
|
|
21
|
+
if (!buf) return null;
|
|
22
|
+
try {
|
|
23
|
+
// The attributedBody is a binary plist containing NSAttributedString
|
|
24
|
+
// The plain text is embedded as a UTF-8 string after a specific marker
|
|
25
|
+
// Look for the streamtyped marker pattern and extract text after it
|
|
26
|
+
const bytes = Buffer.from(buf);
|
|
27
|
+
const str = bytes.toString("latin1");
|
|
28
|
+
|
|
29
|
+
// Strategy 1: Look for NSString content between known markers
|
|
30
|
+
// The text typically appears after "NSString" and before "NSDictionary"
|
|
31
|
+
const nsStringIdx = str.indexOf("NSString");
|
|
32
|
+
if (nsStringIdx !== -1) {
|
|
33
|
+
// Find the start of actual text content
|
|
34
|
+
// It's usually preceded by a length byte
|
|
35
|
+
let searchStart = nsStringIdx + 8;
|
|
36
|
+
// Skip past class info bytes to find the actual text
|
|
37
|
+
for (let i = searchStart; i < Math.min(searchStart + 50, bytes.length); i++) {
|
|
38
|
+
// Look for printable ASCII start after some binary bytes
|
|
39
|
+
if (bytes[i]! >= 0x20 && bytes[i]! < 0x7f) {
|
|
40
|
+
// Found potential text start, now find the end
|
|
41
|
+
let textEnd = i;
|
|
42
|
+
while (textEnd < bytes.length) {
|
|
43
|
+
const b = bytes[textEnd]!;
|
|
44
|
+
// Allow printable ASCII, common UTF-8 continuation bytes, and some control chars
|
|
45
|
+
if (b >= 0x20 || b === 0x0a || b === 0x0d || b === 0x09 || (b >= 0x80 && b <= 0xfe)) {
|
|
46
|
+
textEnd++;
|
|
47
|
+
} else {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (textEnd - i > 1) {
|
|
52
|
+
const extracted = bytes.subarray(i, textEnd).toString("utf-8").trim();
|
|
53
|
+
if (extracted.length > 0) return extracted;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Strategy 2: Find text using the bplist pattern
|
|
60
|
+
// Look for the content between specific byte patterns
|
|
61
|
+
const markers = [
|
|
62
|
+
Buffer.from([0x01, 0x94, 0x84, 0x01]), // Common marker before text
|
|
63
|
+
Buffer.from([0x01, 0x84, 0x01]), // Alternative marker
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const marker of markers) {
|
|
67
|
+
const idx = bytes.indexOf(marker);
|
|
68
|
+
if (idx !== -1) {
|
|
69
|
+
const textStart = idx + marker.length;
|
|
70
|
+
// Read until we hit a non-text byte sequence
|
|
71
|
+
let textEnd = textStart;
|
|
72
|
+
while (textEnd < bytes.length && bytes[textEnd] !== 0x06 && bytes[textEnd] !== 0x86) {
|
|
73
|
+
textEnd++;
|
|
74
|
+
}
|
|
75
|
+
const extracted = bytes.subarray(textStart, textEnd).toString("utf-8").trim();
|
|
76
|
+
if (extracted.length > 0) return extracted;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Strategy 3: Brute force - find the longest printable UTF-8 run
|
|
81
|
+
let bestStart = 0;
|
|
82
|
+
let bestLen = 0;
|
|
83
|
+
let currentStart = 0;
|
|
84
|
+
let currentLen = 0;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
87
|
+
const b = bytes[i]!;
|
|
88
|
+
if (b >= 0x20 || b === 0x0a || b === 0x0d || b === 0x09) {
|
|
89
|
+
if (currentLen === 0) currentStart = i;
|
|
90
|
+
currentLen++;
|
|
91
|
+
} else if (b >= 0xc0 && b <= 0xfe) {
|
|
92
|
+
// UTF-8 multibyte start
|
|
93
|
+
if (currentLen === 0) currentStart = i;
|
|
94
|
+
currentLen++;
|
|
95
|
+
} else if (b >= 0x80 && b <= 0xbf && currentLen > 0) {
|
|
96
|
+
// UTF-8 continuation byte
|
|
97
|
+
currentLen++;
|
|
98
|
+
} else {
|
|
99
|
+
if (currentLen > bestLen) {
|
|
100
|
+
bestStart = currentStart;
|
|
101
|
+
bestLen = currentLen;
|
|
102
|
+
}
|
|
103
|
+
currentLen = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (currentLen > bestLen) {
|
|
107
|
+
bestStart = currentStart;
|
|
108
|
+
bestLen = currentLen;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (bestLen > 1) {
|
|
112
|
+
return bytes.subarray(bestStart, bestStart + bestLen).toString("utf-8").trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate common phone number variations for matching.
|
|
123
|
+
* E.g. "6692333038" → ["+16692333038", "+1 6692333038", ...]
|
|
124
|
+
* "+16692333038" → ["16692333038", "6692333038", ...]
|
|
125
|
+
*/
|
|
126
|
+
function phoneVariations(contact: string): string[] {
|
|
127
|
+
// Only generate variations for phone-number-like strings (digits, +, spaces, dashes)
|
|
128
|
+
const digits = contact.replace(/[\s\-\(\)\.]/g, "");
|
|
129
|
+
if (!/^\+?\d{7,15}$/.test(digits)) return [];
|
|
130
|
+
|
|
131
|
+
const raw = digits.replace(/^\+/, "");
|
|
132
|
+
const variations: string[] = [];
|
|
133
|
+
|
|
134
|
+
if (raw.length === 10) {
|
|
135
|
+
// Looks like a US number without country code: add +1
|
|
136
|
+
variations.push(`+1${raw}`, `1${raw}`);
|
|
137
|
+
} else if (raw.length === 11 && raw.startsWith("1")) {
|
|
138
|
+
// Looks like a US number with country code 1: try with +, without 1
|
|
139
|
+
variations.push(`+${raw}`, raw.slice(1), `+${raw.slice(1)}`);
|
|
140
|
+
} else {
|
|
141
|
+
// International: try with/without +
|
|
142
|
+
if (digits.startsWith("+")) {
|
|
143
|
+
variations.push(raw);
|
|
144
|
+
} else {
|
|
145
|
+
variations.push(`+${raw}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return variations;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class IMessageDatabase {
|
|
153
|
+
private db: Database;
|
|
154
|
+
private lastRowId: number = 0;
|
|
155
|
+
private processedRowIds = new Set<number>();
|
|
156
|
+
|
|
157
|
+
constructor() {
|
|
158
|
+
if (!existsSync(CHAT_DB_PATH)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`iMessage database not found at ${CHAT_DB_PATH}.\n` +
|
|
161
|
+
`Make sure you're running on macOS with Messages.app configured.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Open read-only; the DB already uses WAL mode (set by Messages.app)
|
|
167
|
+
this.db = new Database(CHAT_DB_PATH, { readonly: true });
|
|
168
|
+
// Wait up to 3s if Messages.app has the DB locked during WAL checkpoint
|
|
169
|
+
this.db.exec("PRAGMA busy_timeout = 3000");
|
|
170
|
+
} catch (e: any) {
|
|
171
|
+
if (e.message?.includes("unable to open") || e.message?.includes("authorization denied")) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Cannot access iMessage database. Full Disk Access is required.\n\n` +
|
|
174
|
+
`To grant access:\n` +
|
|
175
|
+
`1. Open System Settings → Privacy & Security → Full Disk Access\n` +
|
|
176
|
+
`2. Click the + button\n` +
|
|
177
|
+
`3. Add your terminal app (Terminal.app, iTerm2, Warp, etc.)\n` +
|
|
178
|
+
`4. Restart your terminal and try again`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
initialize(): void {
|
|
186
|
+
// Get the current max ROWID so we only process new messages
|
|
187
|
+
const row = this.db.query("SELECT MAX(ROWID) as maxId FROM message").get() as { maxId: number } | null;
|
|
188
|
+
this.lastRowId = row?.maxId ?? 0;
|
|
189
|
+
// Initialized — only process new messages from here
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getNewMessages(contact: string): IMessage[] {
|
|
193
|
+
// Match contact by exact ID or by trailing digits (so "6692333038" matches "+16692333038")
|
|
194
|
+
const contactDigits = contact.replace(/[^\d]/g, "");
|
|
195
|
+
const isPhone = contactDigits.length >= 7;
|
|
196
|
+
|
|
197
|
+
const query = this.db.query(`
|
|
198
|
+
SELECT
|
|
199
|
+
m.ROWID as rowId,
|
|
200
|
+
m.text,
|
|
201
|
+
m.attributedBody,
|
|
202
|
+
m.date,
|
|
203
|
+
m.is_from_me as isFromMe,
|
|
204
|
+
h.id as sender,
|
|
205
|
+
h.service,
|
|
206
|
+
c.chat_identifier as chatId
|
|
207
|
+
FROM message m
|
|
208
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
209
|
+
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
210
|
+
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
|
|
211
|
+
WHERE m.ROWID > ?1
|
|
212
|
+
AND m.is_from_me = 0
|
|
213
|
+
AND (h.id = ?2 OR (?3 AND REPLACE(REPLACE(REPLACE(h.id, '+', ''), '-', ''), ' ', '') LIKE '%' || ?4))
|
|
214
|
+
AND m.cache_roomnames IS NULL
|
|
215
|
+
ORDER BY m.ROWID ASC
|
|
216
|
+
`);
|
|
217
|
+
|
|
218
|
+
const rows = query.all(this.lastRowId, contact, isPhone ? 1 : 0, contactDigits) as Array<{
|
|
219
|
+
rowId: number;
|
|
220
|
+
text: string | null;
|
|
221
|
+
attributedBody: Buffer | null;
|
|
222
|
+
date: number;
|
|
223
|
+
isFromMe: number;
|
|
224
|
+
sender: string;
|
|
225
|
+
service: string;
|
|
226
|
+
chatId: string | null;
|
|
227
|
+
}>;
|
|
228
|
+
|
|
229
|
+
const messages: IMessage[] = [];
|
|
230
|
+
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
// Skip already-processed ROWIDs (prevents self-message echo loops)
|
|
233
|
+
if (this.processedRowIds.has(row.rowId)) continue;
|
|
234
|
+
|
|
235
|
+
// Update last seen ROWID
|
|
236
|
+
if (row.rowId > this.lastRowId) {
|
|
237
|
+
this.lastRowId = row.rowId;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Try to get text from the text column first, then attributedBody
|
|
241
|
+
let text = row.text;
|
|
242
|
+
if (!text && row.attributedBody) {
|
|
243
|
+
text = extractTextFromAttributedBody(row.attributedBody);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Skip messages with no extractable text
|
|
247
|
+
if (!text || text.trim().length === 0) continue;
|
|
248
|
+
|
|
249
|
+
this.processedRowIds.add(row.rowId);
|
|
250
|
+
|
|
251
|
+
messages.push({
|
|
252
|
+
rowId: row.rowId,
|
|
253
|
+
text: text.trim(),
|
|
254
|
+
sender: row.sender,
|
|
255
|
+
date: appleTimestampToDate(row.date),
|
|
256
|
+
isFromMe: row.isFromMe === 1,
|
|
257
|
+
service: row.service ?? "iMessage",
|
|
258
|
+
chatId: row.chatId,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Prune old processed ROWIDs to prevent memory leak (keep last 1000)
|
|
263
|
+
if (this.processedRowIds.size > 1000) {
|
|
264
|
+
const sorted = [...this.processedRowIds].sort((a, b) => a - b);
|
|
265
|
+
const toRemove = sorted.slice(0, sorted.length - 500);
|
|
266
|
+
for (const id of toRemove) this.processedRowIds.delete(id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return messages;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get the N most recent messages for a contact (both incoming and outgoing).
|
|
274
|
+
*/
|
|
275
|
+
getRecentMessages(contact: string, limit: number = 20): IMessage[] {
|
|
276
|
+
const contactDigits = contact.replace(/[^\d]/g, "");
|
|
277
|
+
const isPhone = contactDigits.length >= 7;
|
|
278
|
+
|
|
279
|
+
const query = this.db.query(`
|
|
280
|
+
SELECT
|
|
281
|
+
m.ROWID as rowId,
|
|
282
|
+
m.text,
|
|
283
|
+
m.attributedBody,
|
|
284
|
+
m.date,
|
|
285
|
+
m.is_from_me as isFromMe,
|
|
286
|
+
COALESCE(h.id, ?1) as sender,
|
|
287
|
+
COALESCE(h.service, 'iMessage') as service,
|
|
288
|
+
c.chat_identifier as chatId
|
|
289
|
+
FROM message m
|
|
290
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
291
|
+
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
292
|
+
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
|
|
293
|
+
WHERE (
|
|
294
|
+
(m.is_from_me = 0 AND (h.id = ?1 OR (?3 AND REPLACE(REPLACE(REPLACE(h.id, '+', ''), '-', ''), ' ', '') LIKE '%' || ?4)))
|
|
295
|
+
OR (m.is_from_me = 1 AND (c.chat_identifier LIKE '%' || ?1 || '%' OR (?3 AND c.chat_identifier LIKE '%' || ?4 || '%')))
|
|
296
|
+
)
|
|
297
|
+
AND m.cache_roomnames IS NULL
|
|
298
|
+
ORDER BY m.ROWID DESC
|
|
299
|
+
LIMIT ?2
|
|
300
|
+
`);
|
|
301
|
+
|
|
302
|
+
const rows = query.all(contact, limit, isPhone ? 1 : 0, contactDigits) as Array<{
|
|
303
|
+
rowId: number;
|
|
304
|
+
text: string | null;
|
|
305
|
+
attributedBody: Buffer | null;
|
|
306
|
+
date: number;
|
|
307
|
+
isFromMe: number;
|
|
308
|
+
sender: string;
|
|
309
|
+
service: string;
|
|
310
|
+
chatId: string | null;
|
|
311
|
+
}>;
|
|
312
|
+
|
|
313
|
+
const messages: IMessage[] = [];
|
|
314
|
+
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
let text = row.text;
|
|
317
|
+
if (!text && row.attributedBody) {
|
|
318
|
+
text = extractTextFromAttributedBody(row.attributedBody);
|
|
319
|
+
}
|
|
320
|
+
if (!text || text.trim().length === 0) continue;
|
|
321
|
+
|
|
322
|
+
messages.push({
|
|
323
|
+
rowId: row.rowId,
|
|
324
|
+
text: text.trim(),
|
|
325
|
+
sender: row.isFromMe === 1 ? "me" : row.sender,
|
|
326
|
+
date: appleTimestampToDate(row.date),
|
|
327
|
+
isFromMe: row.isFromMe === 1,
|
|
328
|
+
service: row.service ?? "iMessage",
|
|
329
|
+
chatId: row.chatId,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Reverse so oldest is first
|
|
334
|
+
return messages.reverse();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get the current max ROWID.
|
|
339
|
+
*/
|
|
340
|
+
getMaxRowId(): number {
|
|
341
|
+
const row = this.db.query("SELECT MAX(ROWID) as maxId FROM message").get() as { maxId: number } | null;
|
|
342
|
+
return row?.maxId ?? 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check if we can access the database (permissions check)
|
|
347
|
+
*/
|
|
348
|
+
verify(): boolean {
|
|
349
|
+
try {
|
|
350
|
+
this.db.query("SELECT COUNT(*) FROM message").get();
|
|
351
|
+
return true;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Look up a contact handle to verify it exists.
|
|
359
|
+
* Tries the exact value first, then common phone number variations
|
|
360
|
+
* (+1 prefix, stripped +1, etc.) so users don't have to get the format exactly right.
|
|
361
|
+
*/
|
|
362
|
+
findContact(contact: string): { id: string; service: string } | null {
|
|
363
|
+
// Try exact match first
|
|
364
|
+
const exact = this.db.query(
|
|
365
|
+
"SELECT id, service FROM handle WHERE id = ? LIMIT 1"
|
|
366
|
+
).get(contact) as { id: string; service: string } | null;
|
|
367
|
+
if (exact) return exact;
|
|
368
|
+
|
|
369
|
+
// Generate phone number variations to try
|
|
370
|
+
const variations = phoneVariations(contact);
|
|
371
|
+
for (const variant of variations) {
|
|
372
|
+
const row = this.db.query(
|
|
373
|
+
"SELECT id, service FROM handle WHERE id = ? LIMIT 1"
|
|
374
|
+
).get(variant) as { id: string; service: string } | null;
|
|
375
|
+
if (row) return row;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Advance the cursor past any new rows (e.g. after sending an outgoing message).
|
|
383
|
+
* Prevents echo loops when bridging with your own number.
|
|
384
|
+
*/
|
|
385
|
+
advanceCursor(): void {
|
|
386
|
+
const row = this.db.query("SELECT MAX(ROWID) as maxId FROM message").get() as { maxId: number } | null;
|
|
387
|
+
if (row?.maxId && row.maxId > this.lastRowId) {
|
|
388
|
+
this.lastRowId = row.maxId;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Advance cursor aggressively to catch echo rows from self-messaging.
|
|
394
|
+
* Polls multiple times over ~1.5s to catch the is_from_me=0 echo copy
|
|
395
|
+
* that iMessage creates when you message yourself.
|
|
396
|
+
*/
|
|
397
|
+
async advanceCursorWithDelay(): Promise<void> {
|
|
398
|
+
this.advanceCursor();
|
|
399
|
+
// Also mark all recent is_from_me=0 rows as processed to catch echoes
|
|
400
|
+
this.markRecentAsProcessed();
|
|
401
|
+
for (let i = 0; i < 3; i++) {
|
|
402
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
403
|
+
this.advanceCursor();
|
|
404
|
+
this.markRecentAsProcessed();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Mark recent outgoing rows (is_from_me=1) as processed to prevent echo loops.
|
|
410
|
+
* Only marks our own messages — never incoming messages from the contact.
|
|
411
|
+
*/
|
|
412
|
+
private markRecentAsProcessed(): void {
|
|
413
|
+
const rows = this.db.query(
|
|
414
|
+
"SELECT ROWID FROM message WHERE ROWID > ?1 AND is_from_me = 1 ORDER BY ROWID ASC LIMIT 50"
|
|
415
|
+
).all(this.lastRowId - 5) as Array<{ ROWID: number }>;
|
|
416
|
+
for (const row of rows) {
|
|
417
|
+
this.processedRowIds.add(row.ROWID);
|
|
418
|
+
if (row.ROWID > this.lastRowId) {
|
|
419
|
+
this.lastRowId = row.ROWID;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
close(): void {
|
|
425
|
+
this.db.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { tmpdir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { unlink } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
const MAX_IMESSAGE_LENGTH = 15000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Remove a temp file, ignoring errors.
|
|
9
|
+
*/
|
|
10
|
+
async function cleanupTmpFile(path: string): Promise<void> {
|
|
11
|
+
try { await unlink(path); } catch {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send an iMessage using JXA (JavaScript for Automation).
|
|
16
|
+
* Uses chats.whose({id: {_contains: contact}}) which is the reliable
|
|
17
|
+
* approach on modern macOS (Sonoma/Sequoia). Message content is read
|
|
18
|
+
* from a temp file to avoid all string escaping issues.
|
|
19
|
+
*/
|
|
20
|
+
async function sendViaJxa(contact: string, text: string): Promise<{ ok: boolean; error?: string }> {
|
|
21
|
+
const tmpFile = join(tmpdir(), `imessage-claude-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
|
|
22
|
+
await Bun.write(tmpFile, text);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const jxaScript = `
|
|
26
|
+
ObjC.import('Foundation');
|
|
27
|
+
function run() {
|
|
28
|
+
var filePath = ${JSON.stringify(tmpFile)};
|
|
29
|
+
var contact = ${JSON.stringify(contact)};
|
|
30
|
+
var nsStr = $.NSString.stringWithContentsOfFileEncodingError(filePath, $.NSUTF8StringEncoding, null);
|
|
31
|
+
var message = nsStr.js;
|
|
32
|
+
var app = Application("Messages");
|
|
33
|
+
var chats = app.chats.whose({id: {_contains: contact}})();
|
|
34
|
+
if (chats.length === 0) throw new Error("No chat found for contact: " + contact);
|
|
35
|
+
app.send(message, {to: chats[0]});
|
|
36
|
+
return "ok";
|
|
37
|
+
}`;
|
|
38
|
+
|
|
39
|
+
const proc = Bun.spawn(["osascript", "-l", "JavaScript", "-e", jxaScript], {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const [exitCode, stderr] = await Promise.all([
|
|
45
|
+
proc.exited,
|
|
46
|
+
new Response(proc.stderr).text(),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (exitCode === 0) return { ok: true };
|
|
50
|
+
return { ok: false, error: stderr.trim() };
|
|
51
|
+
} finally {
|
|
52
|
+
await cleanupTmpFile(tmpFile);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fallback: send using classic AppleScript.
|
|
58
|
+
* Reads message from temp file. Tries participant syntax, then buddy syntax.
|
|
59
|
+
*/
|
|
60
|
+
async function sendViaAppleScript(contact: string, text: string): Promise<{ ok: boolean; error?: string }> {
|
|
61
|
+
const tmpFile = join(tmpdir(), `imessage-claude-${Date.now()}.txt`);
|
|
62
|
+
await Bun.write(tmpFile, text);
|
|
63
|
+
|
|
64
|
+
const escapedContact = contact.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
65
|
+
const escapedPath = tmpFile.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Try participant syntax first (modern macOS)
|
|
69
|
+
const script = `
|
|
70
|
+
set msgFile to POSIX file "${escapedPath}"
|
|
71
|
+
set msgContent to read msgFile as «class utf8»
|
|
72
|
+
tell application "Messages"
|
|
73
|
+
set targetService to 1st account whose service type = iMessage
|
|
74
|
+
set targetBuddy to participant "${escapedContact}" of targetService
|
|
75
|
+
send msgContent to targetBuddy
|
|
76
|
+
end tell`;
|
|
77
|
+
|
|
78
|
+
const proc = Bun.spawn(["osascript", "-e", script], {
|
|
79
|
+
stdout: "pipe",
|
|
80
|
+
stderr: "pipe",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const [exitCode, stderr] = await Promise.all([
|
|
84
|
+
proc.exited,
|
|
85
|
+
new Response(proc.stderr).text(),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
if (exitCode === 0) return { ok: true };
|
|
89
|
+
|
|
90
|
+
// Try buddy syntax (older macOS)
|
|
91
|
+
const fallbackScript = `
|
|
92
|
+
set msgFile to POSIX file "${escapedPath}"
|
|
93
|
+
set msgContent to read msgFile as «class utf8»
|
|
94
|
+
tell application "Messages"
|
|
95
|
+
send msgContent to buddy "${escapedContact}" of (service 1 whose service type is iMessage)
|
|
96
|
+
end tell`;
|
|
97
|
+
|
|
98
|
+
const fallbackProc = Bun.spawn(["osascript", "-e", fallbackScript], {
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "pipe",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const [fbExit, fbErr] = await Promise.all([
|
|
104
|
+
fallbackProc.exited,
|
|
105
|
+
new Response(fallbackProc.stderr).text(),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
if (fbExit === 0) return { ok: true };
|
|
109
|
+
return { ok: false, error: `participant: ${stderr.trim()} | buddy: ${fbErr.trim()}` };
|
|
110
|
+
} finally {
|
|
111
|
+
await cleanupTmpFile(tmpFile);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Send an iMessage to a contact. Tries JXA first, falls back to AppleScript.
|
|
117
|
+
*/
|
|
118
|
+
export async function sendIMessage(contact: string, message: string): Promise<void> {
|
|
119
|
+
const chunks = splitMessage(message, MAX_IMESSAGE_LENGTH);
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
122
|
+
const chunk = chunks[i]!;
|
|
123
|
+
|
|
124
|
+
// Try JXA first (chat-based, most reliable on modern macOS)
|
|
125
|
+
let result = await sendViaJxa(contact, chunk);
|
|
126
|
+
|
|
127
|
+
// Fall back to AppleScript if JXA fails
|
|
128
|
+
if (!result.ok) {
|
|
129
|
+
process.stderr.write(`JXA send failed (${result.error}), trying AppleScript fallback...\n`);
|
|
130
|
+
result = await sendViaAppleScript(contact, chunk);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Failed to send iMessage: ${result.error}\n\n` +
|
|
136
|
+
`Make sure:\n` +
|
|
137
|
+
`1. Messages.app is open and signed in to iMessage\n` +
|
|
138
|
+
`2. Your terminal has Automation permission for Messages\n` +
|
|
139
|
+
` (System Settings → Privacy & Security → Automation)\n` +
|
|
140
|
+
`3. You have an existing chat with this contact in Messages`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Small delay between chunks to maintain order
|
|
145
|
+
if (i < chunks.length - 1) {
|
|
146
|
+
await Bun.sleep(500);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Split a long message into chunks at natural breakpoints.
|
|
153
|
+
*/
|
|
154
|
+
function splitMessage(text: string, maxLength: number): string[] {
|
|
155
|
+
if (text.length <= maxLength) return [text];
|
|
156
|
+
|
|
157
|
+
const chunks: string[] = [];
|
|
158
|
+
let remaining = text;
|
|
159
|
+
|
|
160
|
+
while (remaining.length > 0) {
|
|
161
|
+
if (remaining.length <= maxLength) {
|
|
162
|
+
chunks.push(remaining);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let splitIdx = remaining.lastIndexOf("\n\n", maxLength);
|
|
167
|
+
if (splitIdx <= 0) splitIdx = remaining.lastIndexOf("\n", maxLength);
|
|
168
|
+
if (splitIdx <= 0) splitIdx = remaining.lastIndexOf(" ", maxLength);
|
|
169
|
+
if (splitIdx <= 0) splitIdx = maxLength;
|
|
170
|
+
|
|
171
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
172
|
+
remaining = remaining.slice(splitIdx).trimStart();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (chunks.length > 1) {
|
|
176
|
+
return chunks.map((chunk, i) => `[${i + 1}/${chunks.length}]\n${chunk}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return chunks;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Test that we can reach Messages.app (permissions check).
|
|
184
|
+
*/
|
|
185
|
+
export async function verifySendPermission(): Promise<boolean> {
|
|
186
|
+
try {
|
|
187
|
+
const proc = Bun.spawn(["osascript", "-e", 'tell application "Messages" to get name'], {
|
|
188
|
+
stdout: "pipe",
|
|
189
|
+
stderr: "pipe",
|
|
190
|
+
});
|
|
191
|
+
const exitCode = await proc.exited;
|
|
192
|
+
return exitCode === 0;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/index.ts
ADDED