@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.
- package/LICENSE +21 -0
- package/README.md +279 -2
- package/examples/telegram.ts +82 -0
- package/package.json +25 -4
- package/skills/pigeon/SKILL.md +25 -0
- package/src/cli/format.ts +65 -0
- package/src/cli/main.ts +134 -0
- package/src/config.ts +70 -0
- package/src/define.ts +6 -0
- package/src/handles.ts +22 -0
- package/src/index.ts +11 -0
- package/src/pigeon.ts +187 -0
- package/src/redact.ts +80 -0
- package/src/sources/imessage/contacts.ts +50 -0
- package/src/sources/imessage/index.ts +282 -0
- package/src/sources/imessage/send.ts +54 -0
- package/src/sources/imessage/typedstream.ts +54 -0
- package/src/time.ts +37 -0
- package/src/types.ts +74 -0
|
@@ -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
|
+
}
|