@avgvstvs96/pigeon 0.0.0-stage → 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.
@@ -0,0 +1,282 @@
1
+ import { Database, type Statement } from "bun:sqlite";
2
+ import { existsSync, watch as watchDir } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { handleKey, isPhone } from "../../handles.ts";
6
+ import { defineSource } from "../../define.ts";
7
+ import type { Attachment, Chat, Message, Person, Query } from "../../types.ts";
8
+ import { loadContacts } from "./contacts.ts";
9
+ import { send } from "./send.ts";
10
+ import { decodeAttributedBody } from "./typedstream.ts";
11
+
12
+ export interface IMessageOptions {
13
+ /** Defaults to "imessage". Give a second source (an archive, say) its own id. */
14
+ id?: string;
15
+ label?: string;
16
+ /** Path to a chat.db. Defaults to the live one in ~/Library/Messages. */
17
+ db?: string;
18
+ /** The Contacts folder names are read from, or `false` to show raw handles. */
19
+ contacts?: string | false;
20
+ }
21
+
22
+ /** chat.db stores dates as nanoseconds since 2001-01-01 (seconds in very old databases). */
23
+ const APPLE_EPOCH_MS = 978_307_200_000;
24
+ const toAppleTime = (date: Date) => (date.getTime() - APPLE_EPOCH_MS) * 1e6;
25
+ const fromAppleTime = (value: number) => new Date((value > 1e12 ? value / 1e6 : value * 1e3) + APPLE_EPOCH_MS);
26
+
27
+ /** Tapbacks are stored as messages quoting the text they react to; they never count as messages. */
28
+ const IS_CONTENT = "m.item_type = 0 AND m.associated_message_type NOT BETWEEN 2000 AND 3999";
29
+
30
+ const MESSAGE_COLUMNS = `
31
+ m.ROWID AS rowid, m.guid, m.text, m.attributedBody AS body, m.date, m.is_from_me AS fromMe,
32
+ m.service, m.thread_originator_guid AS replyTo, m.cache_has_attachments AS hasAttachments,
33
+ h.id AS handle, c.chat_identifier AS chat`;
34
+
35
+ interface MessageRow {
36
+ rowid: number;
37
+ guid: string;
38
+ text: string | null;
39
+ body: Uint8Array | null;
40
+ date: number;
41
+ fromMe: number;
42
+ service: string | null;
43
+ replyTo: string | null;
44
+ hasAttachments: number;
45
+ handle: string | null;
46
+ chat: string | null;
47
+ }
48
+
49
+ interface AttachmentRow {
50
+ filename: string | null;
51
+ name: string | null;
52
+ mime: string | null;
53
+ }
54
+
55
+ interface ChatRow {
56
+ rowid: number;
57
+ guid: string;
58
+ identifier: string;
59
+ displayName: string | null;
60
+ style: number;
61
+ lastDate: number | null;
62
+ }
63
+
64
+ const expandHome = (path: string) => (path.startsWith("~/") ? join(homedir(), path.slice(2)) : path);
65
+
66
+ const OBJECT_REPLACEMENT = //g;
67
+
68
+ export function imessage(options: IMessageOptions = {}) {
69
+ const id = options.id ?? "imessage";
70
+ const path = expandHome(options.db ?? "~/Library/Messages/chat.db");
71
+ const contactsDir = options.contacts ?? "~/Library/Application Support/AddressBook";
72
+
73
+ let db: Database | undefined;
74
+ const open = () => {
75
+ if (db) return db;
76
+ try {
77
+ db = new Database(path, { readonly: true });
78
+ db.query("SELECT 1 FROM message LIMIT 1").get();
79
+ return db;
80
+ } catch (err) {
81
+ db = undefined;
82
+ throw new Error(
83
+ `can't read ${path} (${err}). On macOS, give your terminal Full Disk Access in System Settings › Privacy & Security.`,
84
+ );
85
+ }
86
+ };
87
+
88
+ let contacts: Map<string, string> | undefined;
89
+ const nameOf = (handle: string) => {
90
+ contacts ??= contactsDir === false ? new Map() : loadContacts(expandHome(contactsDir));
91
+ return contacts.get(handleKey(handle)) ?? handle;
92
+ };
93
+
94
+ /** One chat per identifier: Messages keeps separate iMessage and SMS rows for the same person and shows them as one. */
95
+ let chatCache: { chats: Map<string, Chat>; guids: Map<string, string> } | undefined;
96
+ const loadChats = () => {
97
+ const rows = open()
98
+ .query<ChatRow, []>(
99
+ `SELECT c.ROWID AS rowid, c.guid, c.chat_identifier AS identifier, c.display_name AS displayName, c.style,
100
+ (SELECT MAX(message_date) FROM chat_message_join WHERE chat_id = c.ROWID) AS lastDate
101
+ FROM chat c ORDER BY lastDate`,
102
+ )
103
+ .all();
104
+ const people = new Map<number, string[]>();
105
+ for (const row of open()
106
+ .query<{ chat: number; handle: string }, []>(
107
+ "SELECT j.chat_id AS chat, h.id AS handle FROM chat_handle_join j JOIN handle h ON h.ROWID = j.handle_id",
108
+ )
109
+ .all()) {
110
+ people.set(row.chat, [...(people.get(row.chat) ?? []), row.handle]);
111
+ }
112
+
113
+ const chats = new Map<string, Chat>();
114
+ const guids = new Map<string, string>();
115
+ for (const row of rows) {
116
+ if (!row.identifier) continue;
117
+ guids.set(row.identifier, row.guid);
118
+ const previous = chats.get(row.identifier);
119
+ const handles = new Set([...(previous?.people.map((p) => p.id) ?? []), ...(people.get(row.rowid) ?? [])]);
120
+ const members: Person[] = [...handles].map((handle) => ({ id: handle, name: nameOf(handle) }));
121
+ const group = row.style === 43;
122
+ const date = row.lastDate ? fromAppleTime(row.lastDate) : previous?.date;
123
+ const name =
124
+ row.displayName ||
125
+ previous?.name ||
126
+ (group ? members.map((p) => p.name).join(", ") || row.identifier : nameOf(row.identifier));
127
+ chats.set(row.identifier, { id: row.identifier, source: id, name, people: members, group, date });
128
+ }
129
+ chatCache = { chats, guids };
130
+ return chatCache;
131
+ };
132
+ const chatNamed = (identifier: string) => (chatCache ?? loadChats()).chats.get(identifier)?.name ?? nameOf(identifier);
133
+
134
+ let attachmentQuery: Statement<AttachmentRow, [number]> | undefined;
135
+ const attachmentsOf = (rowid: number): Attachment[] => {
136
+ attachmentQuery ??= open().query(
137
+ `SELECT a.filename, a.transfer_name AS name, a.mime_type AS mime
138
+ FROM message_attachment_join j JOIN attachment a ON a.ROWID = j.attachment_id
139
+ WHERE j.message_id = ?`,
140
+ );
141
+ return attachmentQuery
142
+ .all(rowid)
143
+ .filter((a) => !a.name?.endsWith(".pluginPayloadAttachment"))
144
+ .map((a) => {
145
+ // With Messages in iCloud, older files often live only in the cloud.
146
+ const path = a.filename ? expandHome(a.filename) : undefined;
147
+ return {
148
+ name: a.name ?? path?.split("/").pop() ?? "attachment",
149
+ ...(path && existsSync(path) && { path }),
150
+ ...(a.mime && { mime: a.mime }),
151
+ };
152
+ });
153
+ };
154
+
155
+ const toMessage = (row: MessageRow): Message | undefined => {
156
+ const raw = row.text || (row.body ? decodeAttributedBody(row.body) : "");
157
+ const text = raw.replace(OBJECT_REPLACEMENT, "").trim();
158
+ const attachments = row.hasAttachments ? attachmentsOf(row.rowid) : [];
159
+ if (!row.chat || (!text && attachments.length === 0)) return undefined;
160
+ return {
161
+ id: row.guid,
162
+ source: id,
163
+ chat: { id: row.chat, name: chatNamed(row.chat) },
164
+ from: row.fromMe ? { id: "me", name: "me" } : { id: row.handle ?? "unknown", name: row.handle ? nameOf(row.handle) : "unknown" },
165
+ text,
166
+ date: fromAppleTime(row.date),
167
+ attachments,
168
+ ...(row.replyTo && { replyTo: row.replyTo }),
169
+ ...(row.service && { service: row.service }),
170
+ };
171
+ };
172
+
173
+ return defineSource({
174
+ id,
175
+ label: options.label ?? "iMessage",
176
+
177
+ chats() {
178
+ return [...loadChats().chats.values()].filter((c) => c.date);
179
+ },
180
+
181
+ *messages({ chat, since, until }: Query) {
182
+ const where = [IS_CONTENT];
183
+ const params: Record<string, string | number> = {};
184
+ if (chat !== undefined) {
185
+ where.push("cmj.chat_id IN (SELECT ROWID FROM chat WHERE chat_identifier = $chat)");
186
+ params.$chat = chat;
187
+ }
188
+ if (since) {
189
+ where.push("cmj.message_date >= $since");
190
+ params.$since = toAppleTime(since);
191
+ }
192
+ if (until) {
193
+ where.push("cmj.message_date < $until");
194
+ params.$until = toAppleTime(until);
195
+ }
196
+ const statement = open().prepare<MessageRow, Record<string, string | number>>(
197
+ `SELECT ${MESSAGE_COLUMNS}
198
+ FROM chat_message_join cmj
199
+ JOIN message m ON m.ROWID = cmj.message_id
200
+ JOIN chat c ON c.ROWID = cmj.chat_id
201
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
202
+ WHERE ${where.join(" AND ")}
203
+ ORDER BY cmj.message_date DESC, cmj.message_id DESC`,
204
+ );
205
+ const seen = new Set<number>();
206
+ try {
207
+ for (const row of statement.iterate(params)) {
208
+ if (seen.has(row.rowid)) continue;
209
+ seen.add(row.rowid);
210
+ const message = toMessage(row);
211
+ if (message) yield message;
212
+ }
213
+ } finally {
214
+ statement.finalize();
215
+ }
216
+ },
217
+
218
+ async *watch({ chat }: Query) {
219
+ const after = open().query<MessageRow & { content: number }, [number]>(
220
+ `SELECT ${MESSAGE_COLUMNS}, (${IS_CONTENT}) AS content
221
+ FROM message m
222
+ LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
223
+ LEFT JOIN chat c ON c.ROWID = cmj.chat_id
224
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
225
+ WHERE m.ROWID > ? ORDER BY m.ROWID`,
226
+ );
227
+ let cursor = open().query<{ max: number | null }, []>("SELECT MAX(ROWID) AS max FROM message").get()?.max ?? 0;
228
+ const waiting = new Map<number, number>();
229
+ for await (const _ of changes(path)) {
230
+ for (const row of after.all(cursor)) {
231
+ // Messages writes the row before linking it to its chat; retry a few times before giving up on it.
232
+ if (row.content && !row.chat && (waiting.get(row.rowid) ?? 0) < 20) {
233
+ waiting.set(row.rowid, (waiting.get(row.rowid) ?? 0) + 1);
234
+ break;
235
+ }
236
+ waiting.delete(row.rowid);
237
+ cursor = row.rowid;
238
+ if (!row.content || (chat !== undefined && row.chat !== chat)) continue;
239
+ if (row.chat && !chatCache?.chats.has(row.chat)) loadChats();
240
+ const message = toMessage(row);
241
+ if (message) yield message;
242
+ }
243
+ }
244
+ },
245
+
246
+ async send(chat, message) {
247
+ const guid = (chatCache ?? loadChats()).guids.get(chat);
248
+ if (guid) return send({ chat: guid }, message);
249
+ if (isPhone(chat) || chat.includes("@")) return send({ handle: chat }, message);
250
+ throw new Error(`no iMessage chat "${chat}"`);
251
+ },
252
+ });
253
+ }
254
+
255
+ /** Wakes on every write to the database folder (chat.db-wal, mostly), and every few seconds in case an event is missed. */
256
+ async function* changes(dbPath: string, fallbackMs = 3000): AsyncGenerator<void> {
257
+ let wake: (() => void) | undefined;
258
+ let dirty = false;
259
+ const watcher = watchDir(dirname(dbPath), () => {
260
+ dirty = true;
261
+ wake?.();
262
+ });
263
+ try {
264
+ while (true) {
265
+ if (!dirty) {
266
+ await new Promise<void>((resolve) => {
267
+ const timer = setTimeout(resolve, fallbackMs);
268
+ wake = () => {
269
+ clearTimeout(timer);
270
+ resolve();
271
+ };
272
+ });
273
+ await Bun.sleep(50);
274
+ }
275
+ dirty = false;
276
+ wake = undefined;
277
+ yield;
278
+ }
279
+ } finally {
280
+ watcher.close();
281
+ }
282
+ }
@@ -0,0 +1,54 @@
1
+ import { copyFile, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+ import type { Outgoing } from "../../types.ts";
5
+
6
+ const SCRIPT = `
7
+ on run argv
8
+ set target to item 1 of argv
9
+ set body to item 3 of argv
10
+ tell application "Messages"
11
+ if item 2 of argv is "chat" then
12
+ set recipient to chat id target
13
+ else
14
+ set recipient to buddy target of (first service whose service type is iMessage)
15
+ end if
16
+ if body is not "" then send body to recipient
17
+ repeat with i from 4 to count of argv
18
+ send (POSIX file (item i of argv) as alias) to recipient
19
+ end repeat
20
+ end tell
21
+ end run`;
22
+
23
+ /** Messages reliably reads files only from inside its own folder, so copy them there first. */
24
+ async function stage(file: string): Promise<string> {
25
+ const dir = join(homedir(), "Library/Messages/Attachments/pigeon", crypto.randomUUID());
26
+ await mkdir(dir, { recursive: true });
27
+ const staged = join(dir, basename(file));
28
+ await copyFile(resolve(file), staged);
29
+ return staged;
30
+ }
31
+
32
+ export type Recipient = { chat: string } | { handle: string };
33
+
34
+ const TIMEOUT_MS = 30_000;
35
+
36
+ export async function send(to: Recipient, { text = "", files = [] }: Outgoing): Promise<void> {
37
+ if (!text && files.length === 0) throw new Error("nothing to send");
38
+ const staged = await Promise.all(files.map(stage));
39
+ const [kind, id] = "chat" in to ? ["chat", to.chat] : ["handle", to.handle];
40
+ const proc = Bun.spawn(["osascript", "-", id, kind, text, ...staged], {
41
+ stdin: new Blob([SCRIPT]),
42
+ stdout: "ignore",
43
+ stderr: "pipe",
44
+ timeout: TIMEOUT_MS,
45
+ });
46
+ const code = await proc.exited;
47
+ if (proc.signalCode) {
48
+ throw new Error(
49
+ "Messages didn't answer. The first send from a new app or ssh needs a one-time OK: " +
50
+ "check for a prompt on the Mac, or System Settings › Privacy & Security › Automation.",
51
+ );
52
+ }
53
+ if (code !== 0) throw new Error(`Messages couldn't send: ${(await new Response(proc.stderr).text()).trim()}`);
54
+ }
@@ -0,0 +1,54 @@
1
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
2
+
3
+ function ascii(bytes: Uint8Array, start: number, end: number): string {
4
+ return String.fromCharCode(...bytes.subarray(start, end));
5
+ }
6
+
7
+ function readString(bytes: Uint8Array, at: number, littleEndian: boolean): string {
8
+ const head = bytes[at];
9
+ if (head === undefined) return "";
10
+ let start = at + 1;
11
+ let length = head;
12
+ if (head === 0x81 || head === 0x82) {
13
+ const width = head === 0x81 ? 2 : 4;
14
+ if (start + width > bytes.length) return "";
15
+ length = 0;
16
+ for (let i = 0; i < width; i++) {
17
+ const shift = (littleEndian ? i : width - 1 - i) * 8;
18
+ length += bytes[start + i]! * 2 ** shift;
19
+ }
20
+ start += width;
21
+ } else if (head >= 0x80 && head <= 0x91) {
22
+ return "";
23
+ }
24
+ if (start + length > bytes.length) return "";
25
+ try {
26
+ return utf8.decode(bytes.subarray(start, start + length));
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Message text from `message.attributedBody`, which newer macOS writes instead
34
+ * of `message.text`. It's an NSAttributedString in NeXT's typedstream format;
35
+ * the body is the first NSString, framed as 0x01 0x2b ("+") then a length.
36
+ */
37
+ export function decodeAttributedBody(bytes: Uint8Array): string {
38
+ if (bytes.length === 0) return "";
39
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
40
+ try {
41
+ return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+ if (bytes[0] !== 0x04 || bytes[1] !== 0x0b || bytes.length < 13) return "";
47
+ const signature = ascii(bytes, 2, 13);
48
+ if (signature !== "streamtyped" && signature !== "typedstream") return "";
49
+
50
+ for (let i = 13; i + 1 < bytes.length; i++) {
51
+ if (bytes[i] === 0x01 && bytes[i + 1] === 0x2b) return readString(bytes, i + 2, signature === "streamtyped");
52
+ }
53
+ return "";
54
+ }
package/src/time.ts ADDED
@@ -0,0 +1,37 @@
1
+ export type DateInput = Date | number | string;
2
+
3
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
4
+ const AGO = /^(\d+(?:\.\d+)?)([mhdw])$/;
5
+ const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
6
+
7
+ /** Bare dates ("2026-10-06") are local midnight, not UTC like `new Date()` reads them. */
8
+ export function toDate(input: DateInput): Date {
9
+ if (input instanceof Date) return input;
10
+ if (typeof input === "number") return new Date(input);
11
+ const day = DAY.exec(input.trim());
12
+ const date = day ? new Date(+day[1]!, +day[2]! - 1, +day[3]!) : new Date(input);
13
+ if (Number.isNaN(date.getTime())) throw new Error(`not a date: ${input}`);
14
+ return date;
15
+ }
16
+
17
+ /** A calendar day in local time, as [start, end). */
18
+ export function dayRange(input: DateInput): [Date, Date] {
19
+ const start = new Date(toDate(input));
20
+ start.setHours(0, 0, 0, 0);
21
+ const end = new Date(start);
22
+ end.setDate(end.getDate() + 1);
23
+ return [start, end];
24
+ }
25
+
26
+ /** CLI times: "30m", "12h", "3d", "2w" ago, "today" or "yesterday" (from midnight), or any date `toDate` reads. */
27
+ export function parseWhen(input: string, now = Date.now()): Date {
28
+ const word = input.trim().toLowerCase();
29
+ if (word === "today" || word === "yesterday") {
30
+ const [start] = dayRange(now);
31
+ if (word === "yesterday") start.setDate(start.getDate() - 1);
32
+ return start;
33
+ }
34
+ const ago = AGO.exec(word);
35
+ if (ago) return new Date(now - Number(ago[1]) * UNIT_MS[ago[2] as keyof typeof UNIT_MS]);
36
+ return toDate(input);
37
+ }
package/src/types.ts ADDED
@@ -0,0 +1,74 @@
1
+ export interface Person {
2
+ /** Phone number, email, or any stable handle. `"me"` for messages you sent. */
3
+ id: string;
4
+ name: string;
5
+ }
6
+
7
+ export interface Attachment {
8
+ name: string;
9
+ path?: string;
10
+ mime?: string;
11
+ }
12
+
13
+ export interface Chat {
14
+ /** Unique within its source. For iMessage 1:1 chats this is the other person's handle. */
15
+ id: string;
16
+ source: string;
17
+ name: string;
18
+ /** Everyone in the chat except you. */
19
+ people: Person[];
20
+ group: boolean;
21
+ /** When the last message was sent. */
22
+ date?: Date;
23
+ }
24
+
25
+ export interface Message {
26
+ /** Unique within its source. */
27
+ id: string;
28
+ source: string;
29
+ chat: { id: string; name: string };
30
+ from: Person;
31
+ text: string;
32
+ date: Date;
33
+ attachments: Attachment[];
34
+ /** Id of the message this one replies to. */
35
+ replyTo?: string;
36
+ service?: string;
37
+ /** Set by `redact()` when some or all of this message was hidden. */
38
+ redacted?: true;
39
+ }
40
+
41
+ export interface Query {
42
+ /** A chat id from this source. */
43
+ chat?: string;
44
+ since?: Date;
45
+ until?: Date;
46
+ }
47
+
48
+ export interface Outgoing {
49
+ text?: string;
50
+ files?: string[];
51
+ }
52
+
53
+ /**
54
+ * Anything that holds messages. Only `chats` and `messages` are required, so a
55
+ * read-only source is a few lines; `watch` and `send` light up those commands.
56
+ */
57
+ export interface Source {
58
+ id: string;
59
+ label?: string;
60
+ chats(): Promise<Chat[]> | Chat[];
61
+ /** Newest first. `query` is a hint: pigeon re-checks it, so a source may ignore it. */
62
+ messages(query: Query): AsyncIterable<Message> | Iterable<Message>;
63
+ /** New messages as they arrive. */
64
+ watch?(query: Query): AsyncIterable<Message>;
65
+ send?(chat: string, message: Outgoing): Promise<void>;
66
+ }
67
+
68
+ /** Keep (`true`), drop (`false`), or replace (return a new message). */
69
+ export type Filter = (message: Message) => boolean | Message;
70
+
71
+ export interface Config {
72
+ sources?: Source[];
73
+ filters?: Filter[];
74
+ }