@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bassim Shahidy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,280 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pigeon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your messages, from the terminal. Read, search, watch and send iMessage, with
|
|
4
|
+
filters that decide what anything reading through pigeon gets to see.
|
|
5
|
+
|
|
6
|
+
Built for people and for agents: plain-text transcripts by default, JSON lines
|
|
7
|
+
with `--json`. Runs on Bun.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ pigeon read
|
|
11
|
+
── Mom · +12125550101
|
|
12
|
+
Oct 11 13:24 Mom: are you still coming sunday?
|
|
13
|
+
Oct 11 13:34 me: yes! bringing dessert
|
|
14
|
+
── Riley Quinn · +12125550102
|
|
15
|
+
Oct 12 14:24 Riley Quinn: the wifi password is [redacted]
|
|
16
|
+
Oct 12 14:34 me: thank you!!
|
|
17
|
+
── Book club · chat204
|
|
18
|
+
Oct 13 05:24 Sam Okafor: chapter 9 for thursday?
|
|
19
|
+
Oct 13 05:39 me: on it [attachment: IMG_2041.heic]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Read and search** every chat, with names from your Contacts
|
|
23
|
+
- **Watch** new messages as they arrive, ready to pipe into a script or an agent
|
|
24
|
+
- **Send** text and files through Messages
|
|
25
|
+
- **Redact** a chat, a day, a sender or a string, on every command, with no way around it from the CLI
|
|
26
|
+
- **Make it yours** in one `config.ts`: [filters](#filters) and [sources](#sources) for Telegram, WhatsApp, anything, built on the same API as iMessage
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
pigeon needs [Bun](https://bun.sh) and macOS for iMessage. On Linux it can
|
|
31
|
+
still read a `chat.db` copied from a Mac.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun add -g github:AVGVSTVS96/pigeon
|
|
35
|
+
pigeon --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Give your terminal **Full Disk Access** (System Settings › Privacy & Security)
|
|
39
|
+
so it can read `~/Library/Messages/chat.db`. The first `pigeon send` asks once
|
|
40
|
+
for permission to control Messages.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pigeon chats # chats, most recent first
|
|
46
|
+
pigeon chats book # chats whose name contains "book"
|
|
47
|
+
pigeon read mom # the last 30 messages with Mom
|
|
48
|
+
pigeon read # the last 30 messages anywhere
|
|
49
|
+
pigeon read -s 1d # everything from the last 24 hours
|
|
50
|
+
pigeon read -s yesterday -u today # all of yesterday
|
|
51
|
+
pigeon search dinner sunday # messages with every word, newest 30
|
|
52
|
+
pigeon search pie -c mom # ...in one chat
|
|
53
|
+
pigeon watch # new messages, live
|
|
54
|
+
pigeon watch --json | jq . # ...as JSON lines
|
|
55
|
+
pigeon send mom "landed!" # send a message (to an id or a chat's full name)
|
|
56
|
+
pigeon send mom -f pie.jpg # send a file
|
|
57
|
+
echo "on my way" | pigeon send +12125550101
|
|
58
|
+
pigeon config # which config loaded, and how many filters
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A chat is its id or its name. For a 1:1 iMessage chat the id is the other
|
|
62
|
+
person's phone number or email, written any way you like (`+1 212 555 0101`,
|
|
63
|
+
`2125550101`). Group chats have ids like `chat204`, shown by `pigeon chats`.
|
|
64
|
+
|
|
65
|
+
| Flag | Meaning |
|
|
66
|
+
| ---- | ------- |
|
|
67
|
+
| `-n`, `--limit <n>` | how many to show (default 30) |
|
|
68
|
+
| `-s`, `--since <when>` | `30m`, `12h`, `3d`, `2w`, `today`, `yesterday`, or a date like `2026-10-01` |
|
|
69
|
+
| `-u`, `--until <when>` | same forms as `--since` |
|
|
70
|
+
| `-c`, `--chat <chat>` | limit `search` or `watch` to one chat |
|
|
71
|
+
| `-f`, `--file <path>` | attach a file to `send` (repeatable) |
|
|
72
|
+
| `--json` | one JSON object per line |
|
|
73
|
+
|
|
74
|
+
`read` and `search` show the newest matches, printed oldest first like a
|
|
75
|
+
conversation. Dates are local time, in text and in JSON.
|
|
76
|
+
|
|
77
|
+
<details>
|
|
78
|
+
<summary>The JSON shape</summary>
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"id": "6A1F3B7C-…",
|
|
83
|
+
"source": "imessage",
|
|
84
|
+
"chat": { "id": "+12125550102", "name": "Riley Quinn" },
|
|
85
|
+
"from": { "id": "+12125550102", "name": "Riley Quinn" },
|
|
86
|
+
"text": "the wifi password is [redacted]",
|
|
87
|
+
"date": "2026-10-12T14:24:45-07:00",
|
|
88
|
+
"attachments": [],
|
|
89
|
+
"service": "iMessage",
|
|
90
|
+
"redacted": true
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`from.id` is `"me"` for messages you sent. `replyTo` holds the id of the
|
|
95
|
+
message being replied to. `attachments` entries have a `name`, a `mime`
|
|
96
|
+
when known, and a `path` when the file is on this Mac (with Messages in
|
|
97
|
+
iCloud, older files may only be in the cloud).
|
|
98
|
+
|
|
99
|
+
</details>
|
|
100
|
+
|
|
101
|
+
## Make it yours
|
|
102
|
+
|
|
103
|
+
Everything is configured from one file, `~/.config/pigeon/config.ts`. It's
|
|
104
|
+
plain TypeScript: export a function that receives pigeon's API and returns
|
|
105
|
+
your `sources` and `filters`.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// ~/.config/pigeon/config.ts
|
|
109
|
+
import type { ConfigFactory } from "@avgvstvs96/pigeon";
|
|
110
|
+
|
|
111
|
+
export default (({ imessage, redact }) => ({
|
|
112
|
+
sources: [imessage()],
|
|
113
|
+
filters: [
|
|
114
|
+
redact({ chat: "+12125550102", on: "2026-10-06" }), // one chat, one day
|
|
115
|
+
redact({ match: /\b\d{3}-\d{2}-\d{4}\b/ }), // anything shaped like an SSN
|
|
116
|
+
(m) => !/verification code/i.test(m.text), // drop 2FA texts entirely
|
|
117
|
+
],
|
|
118
|
+
})) satisfies ConfigFactory;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Without a config, pigeon reads iMessage with no filters.
|
|
122
|
+
|
|
123
|
+
### Filters
|
|
124
|
+
|
|
125
|
+
A filter is a function that gets each message and returns:
|
|
126
|
+
|
|
127
|
+
| Return | Effect |
|
|
128
|
+
| ------ | ------ |
|
|
129
|
+
| `true` | keep it |
|
|
130
|
+
| `false` | drop it |
|
|
131
|
+
| a message | show this one instead |
|
|
132
|
+
|
|
133
|
+
Filters run in order, on every command: `read`, `search`, `watch`, and the
|
|
134
|
+
[library](#library). There is no flag to skip them, so whatever reads your
|
|
135
|
+
messages through pigeon (you, a script, an agent) sees exactly what your
|
|
136
|
+
filters allow.
|
|
137
|
+
|
|
138
|
+
pigeon fails closed. A filter that throws drops the message it threw on. A
|
|
139
|
+
config that fails to load stops pigeon with an error instead of running
|
|
140
|
+
without its filters, and so does one with a typo in it (`filter:` instead of
|
|
141
|
+
`filters:`). Search runs *after* filters, so hidden text can't be found by
|
|
142
|
+
searching for it.
|
|
143
|
+
|
|
144
|
+
Filters apply to messages. `pigeon chats` still lists every chat, with its
|
|
145
|
+
name, people and the time of its last message.
|
|
146
|
+
|
|
147
|
+
### Redact
|
|
148
|
+
|
|
149
|
+
`redact()` is the built-in filter for hiding things. It keeps the message
|
|
150
|
+
but replaces its text with `[redacted]`, so a reader still sees that
|
|
151
|
+
something was said, and when, but not what. Every field narrows the rule:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
redact({ chat: "+12125550102" }) // a whole chat
|
|
155
|
+
redact({ chat: "chat204", on: "2026-10-06" }) // one day of a group chat
|
|
156
|
+
redact({ from: "+12125550102", since: "2026-09-01" }) // one person, from a date on
|
|
157
|
+
redact({ from: "me", until: "2020-01-01" }) // everything you sent before 2020
|
|
158
|
+
redact({ match: ["gate code", /\d{4}/] }) // just these strings, everywhere
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Field | Matches |
|
|
162
|
+
| ----- | ------- |
|
|
163
|
+
| `chat` | a chat id (one or a list): a phone number or email for 1:1 chats, the id from `pigeon chats` for groups |
|
|
164
|
+
| `from` | a sender's handle, or `"me"` (one or a list) |
|
|
165
|
+
| `on` | a whole day, local time: `"2026-10-06"` |
|
|
166
|
+
| `since` / `until` | a time range: a `Date`, a timestamp, or a date string |
|
|
167
|
+
| `match` | strings (case-insensitive) or regexes to hide in the text and attachment names; without it, the whole message is hidden along with its attachments |
|
|
168
|
+
|
|
169
|
+
Phone numbers match however they're written, so `"212-555-0102"` and
|
|
170
|
+
`"+12125550102"` are the same person. Rules take ids, not names: a contact
|
|
171
|
+
or group can be renamed, and a rule that quietly stopped matching would show
|
|
172
|
+
everything it was hiding.
|
|
173
|
+
|
|
174
|
+
### Sources
|
|
175
|
+
|
|
176
|
+
iMessage isn't special: it's declared with the same `defineSource` your
|
|
177
|
+
config gets. A source needs an `id` and two functions:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import type { Source } from "@avgvstvs96/pigeon";
|
|
181
|
+
|
|
182
|
+
const notes: Source = {
|
|
183
|
+
id: "notes",
|
|
184
|
+
chats: () => [{ id: "inbox", source: "notes", name: "Inbox", people: [], group: false }],
|
|
185
|
+
*messages() {
|
|
186
|
+
yield {
|
|
187
|
+
id: "1",
|
|
188
|
+
source: "notes",
|
|
189
|
+
chat: { id: "inbox", name: "Inbox" },
|
|
190
|
+
from: { id: "me", name: "me" },
|
|
191
|
+
text: "remember the pie",
|
|
192
|
+
date: new Date(),
|
|
193
|
+
attachments: [],
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`messages()` yields newest first and may ignore its query (`chat`,
|
|
200
|
+
`since`, `until`): pigeon re-checks every message, so the simplest source is
|
|
201
|
+
just "here are my messages". Add `watch()` to make `pigeon watch` work for it
|
|
202
|
+
and `send()` for `pigeon send`. [`examples/telegram.ts`](examples/telegram.ts)
|
|
203
|
+
is a complete source for Telegram Desktop's JSON export in about 80 lines.
|
|
204
|
+
|
|
205
|
+
Sources merge into one stream by date, and filters apply to all of them:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { telegram } from "./telegram.ts";
|
|
209
|
+
|
|
210
|
+
export default (({ imessage, redact }) => ({
|
|
211
|
+
sources: [imessage(), telegram("~/Downloads/Telegram Desktop/DataExport/result.json")],
|
|
212
|
+
filters: [redact({ match: "the gate code" })],
|
|
213
|
+
})) satisfies ConfigFactory;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`imessage()` takes options too: `db` to read another `chat.db` (a backup,
|
|
217
|
+
or a copy on Linux), `contacts` to point at another Contacts folder or
|
|
218
|
+
`false` to show raw handles, and `id` to tell two iMessage sources apart.
|
|
219
|
+
|
|
220
|
+
## Library
|
|
221
|
+
|
|
222
|
+
The CLI is a thin layer over the same API, which you can use directly:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { open } from "@avgvstvs96/pigeon";
|
|
226
|
+
|
|
227
|
+
const pigeon = await open(); // your config, your filters
|
|
228
|
+
|
|
229
|
+
for await (const m of pigeon.messages({ chat: "mom", since: new Date("2026-10-01") })) {
|
|
230
|
+
console.log(m.from.name, m.text);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for await (const m of pigeon.watch()) {
|
|
234
|
+
if (m.text.includes("call me")) await pigeon.send(m.chat.id, { text: "calling in 5" });
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`pigeon({ sources, filters })` builds one without reading a config file.
|
|
239
|
+
|
|
240
|
+
## For agents
|
|
241
|
+
|
|
242
|
+
Agents read the same transcripts you do, and `--json` when they need
|
|
243
|
+
structure. [`skills/pigeon/SKILL.md`](skills/pigeon/SKILL.md) teaches Claude
|
|
244
|
+
Code, Codex and other skill-aware agents to use pigeon instead of opening
|
|
245
|
+
`chat.db` themselves:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
cp -r skills/pigeon ~/.claude/skills/
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## How it reads iMessage
|
|
252
|
+
|
|
253
|
+
pigeon opens `chat.db` read-only and never writes to it. It reads message
|
|
254
|
+
text from `attributedBody` when newer macOS leaves `text` empty, merges a
|
|
255
|
+
person's iMessage and SMS threads into one chat like Messages does, and
|
|
256
|
+
reads names straight from the Contacts database, so it works over ssh too.
|
|
257
|
+
Tapbacks and system events (renames, people joining) aren't messages and are
|
|
258
|
+
skipped; tapbacks also quote the message they react to, so skipping them keeps
|
|
259
|
+
hidden text from leaking out through a "Loved …".
|
|
260
|
+
|
|
261
|
+
`watch` wakes on every write to the database and checks every few seconds in
|
|
262
|
+
case an event is missed. `send` uses AppleScript, so it needs Messages to be
|
|
263
|
+
signed in and permission to control it.
|
|
264
|
+
|
|
265
|
+
## What redaction promises
|
|
266
|
+
|
|
267
|
+
pigeon never hands out what your filters hide, through any command or the
|
|
268
|
+
library. It can't stop other programs from opening `chat.db` themselves:
|
|
269
|
+
anything with Full Disk Access can. Give your agents pigeon and they have no
|
|
270
|
+
reason to go around it. Anything truly sensitive is safest deleted from
|
|
271
|
+
Messages.
|
|
272
|
+
|
|
273
|
+
## Credits
|
|
274
|
+
|
|
275
|
+
[imsg](https://github.com/steipete/imsg) by Peter Steinberger was a great map
|
|
276
|
+
of `chat.db`'s quirks.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { Chat, Message, Source } from "@avgvstvs96/pigeon";
|
|
3
|
+
|
|
4
|
+
type Text = string | ({ text: string } & Record<string, unknown>);
|
|
5
|
+
|
|
6
|
+
interface Export {
|
|
7
|
+
personal_information?: { user_id: number };
|
|
8
|
+
chats: {
|
|
9
|
+
list: {
|
|
10
|
+
id: number;
|
|
11
|
+
name?: string;
|
|
12
|
+
type: string;
|
|
13
|
+
messages: {
|
|
14
|
+
id: number;
|
|
15
|
+
type: string;
|
|
16
|
+
date_unixtime: string;
|
|
17
|
+
from?: string;
|
|
18
|
+
from_id?: string;
|
|
19
|
+
text: Text | Text[];
|
|
20
|
+
file?: string;
|
|
21
|
+
photo?: string;
|
|
22
|
+
reply_to_message_id?: number;
|
|
23
|
+
}[];
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const plain = (text: Text | Text[]) => [text].flat().map((t) => (typeof t === "string" ? t : t.text)).join("");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Telegram Desktop's "Export chat history" (JSON) as a pigeon source.
|
|
32
|
+
* Read-only: no watch, no send. Copy this file next to your config to use it.
|
|
33
|
+
*/
|
|
34
|
+
export function telegram(path: string): Source {
|
|
35
|
+
let loaded: Promise<{ chats: Chat[]; messages: Message[] }> | undefined;
|
|
36
|
+
|
|
37
|
+
const load = async () => {
|
|
38
|
+
const data: Export = await Bun.file(path.replace(/^~(?=\/)/, homedir())).json();
|
|
39
|
+
const me = data.personal_information && `user${data.personal_information.user_id}`;
|
|
40
|
+
const chats: Chat[] = [];
|
|
41
|
+
const messages: Message[] = [];
|
|
42
|
+
for (const c of data.chats.list) {
|
|
43
|
+
const chat = { id: String(c.id), name: c.name ?? "Saved Messages" };
|
|
44
|
+
const people = new Map<string, string>();
|
|
45
|
+
let last: Date | undefined;
|
|
46
|
+
for (const m of c.messages) {
|
|
47
|
+
if (m.type !== "message") continue;
|
|
48
|
+
const fromMe = m.from_id === me;
|
|
49
|
+
if (!fromMe && m.from_id) people.set(m.from_id, m.from ?? m.from_id);
|
|
50
|
+
last = new Date(Number(m.date_unixtime) * 1000);
|
|
51
|
+
messages.push({
|
|
52
|
+
id: `${c.id}/${m.id}`,
|
|
53
|
+
source: "telegram",
|
|
54
|
+
chat,
|
|
55
|
+
from: fromMe ? { id: "me", name: "me" } : { id: m.from_id ?? "unknown", name: m.from ?? "unknown" },
|
|
56
|
+
text: plain(m.text),
|
|
57
|
+
date: last,
|
|
58
|
+
attachments: [m.photo, m.file].filter((f) => f !== undefined).map((f) => ({ name: f.split("/").pop()! })),
|
|
59
|
+
...(m.reply_to_message_id && { replyTo: `${c.id}/${m.reply_to_message_id}` }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
chats.push({
|
|
63
|
+
...chat,
|
|
64
|
+
source: "telegram",
|
|
65
|
+
people: [...people].map(([id, name]) => ({ id, name })),
|
|
66
|
+
group: !c.type.startsWith("personal"),
|
|
67
|
+
date: last,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
messages.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
71
|
+
return { chats, messages };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: "telegram",
|
|
76
|
+
label: "Telegram",
|
|
77
|
+
chats: async () => (await (loaded ??= load())).chats,
|
|
78
|
+
async *messages() {
|
|
79
|
+
yield* (await (loaded ??= load())).messages;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avgvstvs96/pigeon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your messages, from the terminal. An iMessage CLI with pluggable sources and filters.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": "github:AVGVSTVS96/pigeon",
|
|
8
|
+
"keywords": ["imessage", "messages", "cli", "chat.db", "agents", "redaction"],
|
|
9
|
+
"bin": {
|
|
10
|
+
"pigeon": "src/cli/main.ts"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": ["src", "examples", "skills", "README.md", "LICENSE"],
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "^1.3.14",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pigeon
|
|
3
|
+
description: Read, search, watch and send the user's iMessages (and any other sources they've configured) with the pigeon CLI. Use whenever a task involves their texts or chats, instead of opening chat.db or any Messages database directly.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pigeon
|
|
7
|
+
|
|
8
|
+
`pigeon` reads the user's messages with their filters already applied. Messages
|
|
9
|
+
they've hidden show up as `[redacted]`; that is intentional, so never try to
|
|
10
|
+
recover them, and never read `chat.db` or a copy of it directly.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pigeon chats [name] # find a chat and its id
|
|
14
|
+
pigeon read <chat> [-n 30] # recent messages, oldest first
|
|
15
|
+
pigeon read -s 1d # everything from the last day, all chats
|
|
16
|
+
pigeon search <words...> [-c chat] [-s 2w]
|
|
17
|
+
pigeon watch [chat] --json # stream new messages
|
|
18
|
+
pigeon send <chat> "text" [-f file]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- A chat is its id (a phone number or email for 1:1 chats, `chat…` for groups) or its name. If a name matches several chats, pigeon lists them; retry with the id.
|
|
22
|
+
- `-s/--since` and `-u/--until` take `30m`, `12h`, `3d`, `2w`, `today`, `yesterday` or a date.
|
|
23
|
+
- Add `--json` for one object per line: `chat`, `from` (`id` is `"me"` for the user), `text`, `date` (local time), `attachments`, `replyTo`, `redacted`.
|
|
24
|
+
- Default output is a compact transcript; prefer it for reading, `--json` for processing.
|
|
25
|
+
- Sending is visible to other people: only send when the user asked for that message.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Chat, Message } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
4
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
5
|
+
|
|
6
|
+
export interface Style {
|
|
7
|
+
dim(text: string): string;
|
|
8
|
+
bold(text: string): string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const plain: Style = { dim: (t) => t, bold: (t) => t };
|
|
12
|
+
export const ansi: Style = { dim: (t) => `\x1b[2m${t}\x1b[22m`, bold: (t) => `\x1b[1m${t}\x1b[22m` };
|
|
13
|
+
|
|
14
|
+
/** "Oct 6 21:14", with the year when it isn't this one. */
|
|
15
|
+
export function shortDate(date: Date, now = new Date()): string {
|
|
16
|
+
const year = date.getFullYear() === now.getFullYear() ? "" : ` ${date.getFullYear()}`;
|
|
17
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()}${year} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ISO 8601 in local time with its offset, so a reader sees the same day you do. */
|
|
21
|
+
export function localIso(date: Date): string {
|
|
22
|
+
const offset = -date.getTimezoneOffset();
|
|
23
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
24
|
+
const zone = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
|
|
25
|
+
return (
|
|
26
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
27
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${zone}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function json(value: Chat | Message): string {
|
|
32
|
+
return JSON.stringify(value, function (this: Record<string, unknown>, key, v) {
|
|
33
|
+
const original = this[key];
|
|
34
|
+
return original instanceof Date ? localIso(original) : v;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function chatLine(chat: Chat, style: Style, width: number): string {
|
|
39
|
+
const when = chat.date ? shortDate(chat.date) : "";
|
|
40
|
+
const tag = chat.group ? style.dim(` group of ${chat.people.length + 1}`) : "";
|
|
41
|
+
return `${style.dim(when.padEnd(12))} ${style.bold(chat.name.padEnd(width))} ${style.dim(chat.id)}${tag}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function body(message: Message): string {
|
|
45
|
+
const files = message.attachments.map((a) => `[attachment: ${a.name}]`);
|
|
46
|
+
return [message.text, ...files].filter(Boolean).join(" ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Messages as a transcript, with a header each time the chat changes. */
|
|
50
|
+
export function transcript(style: Style) {
|
|
51
|
+
let chat: string | undefined;
|
|
52
|
+
return (message: Message): string => {
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
const key = `${message.source}\0${message.chat.id}`;
|
|
55
|
+
if (key !== chat) {
|
|
56
|
+
chat = key;
|
|
57
|
+
lines.push(style.bold(`── ${message.chat.name}`) + style.dim(` · ${message.chat.id}`));
|
|
58
|
+
}
|
|
59
|
+
const when = shortDate(message.date);
|
|
60
|
+
const [first, ...rest] = `${message.from.name}: ${body(message)}`.split("\n");
|
|
61
|
+
lines.push(`${style.dim(when)} ${first}`);
|
|
62
|
+
for (const line of rest) lines.push(`${" ".repeat(when.length)} ${line}`);
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { findConfig, loadConfig } from "../config.ts";
|
|
4
|
+
import { pigeon } from "../pigeon.ts";
|
|
5
|
+
import { parseWhen } from "../time.ts";
|
|
6
|
+
import type { Message } from "../types.ts";
|
|
7
|
+
import { ansi, chatLine, json, plain, transcript } from "./format.ts";
|
|
8
|
+
|
|
9
|
+
export const HELP = `pigeon: your messages, from the terminal
|
|
10
|
+
|
|
11
|
+
Usage
|
|
12
|
+
pigeon chats [name] chats, most recent first
|
|
13
|
+
pigeon read [chat] recent messages in a chat, or across all of them
|
|
14
|
+
pigeon search <words...> messages containing every word
|
|
15
|
+
pigeon watch [chat] new messages as they arrive
|
|
16
|
+
pigeon send <chat> [text...] send a message (text from stdin if omitted)
|
|
17
|
+
pigeon config where the config is and what it loaded
|
|
18
|
+
|
|
19
|
+
A chat is its id (a phone number or email for 1:1 chats) or its name.
|
|
20
|
+
|
|
21
|
+
Options
|
|
22
|
+
-n, --limit <n> how many to show (default 30)
|
|
23
|
+
-s, --since <when> "30m", "12h", "3d", "2w", today, yesterday, or a date
|
|
24
|
+
-u, --until <when> same forms as --since
|
|
25
|
+
-c, --chat <chat> limit search or watch to one chat
|
|
26
|
+
-f, --file <path> attach a file to send (repeatable)
|
|
27
|
+
--json one JSON object per line
|
|
28
|
+
-h, --help
|
|
29
|
+
|
|
30
|
+
Filters in ~/.config/pigeon/config.ts apply to every command and can't be skipped.`;
|
|
31
|
+
|
|
32
|
+
const OPTIONS = {
|
|
33
|
+
limit: { type: "string", short: "n" },
|
|
34
|
+
since: { type: "string", short: "s" },
|
|
35
|
+
until: { type: "string", short: "u" },
|
|
36
|
+
chat: { type: "string", short: "c" },
|
|
37
|
+
file: { type: "string", short: "f", multiple: true },
|
|
38
|
+
json: { type: "boolean" },
|
|
39
|
+
help: { type: "boolean", short: "h" },
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export function parse(argv: string[]) {
|
|
43
|
+
const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
44
|
+
const limit = values.limit === undefined ? 30 : Number(values.limit);
|
|
45
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error(`--limit must be a positive whole number`);
|
|
46
|
+
const [command, ...rest] = positionals;
|
|
47
|
+
return {
|
|
48
|
+
command,
|
|
49
|
+
args: rest,
|
|
50
|
+
limit,
|
|
51
|
+
since: values.since === undefined ? undefined : parseWhen(values.since),
|
|
52
|
+
until: values.until === undefined ? undefined : parseWhen(values.until),
|
|
53
|
+
chat: values.chat,
|
|
54
|
+
files: values.file ?? [],
|
|
55
|
+
json: values.json ?? false,
|
|
56
|
+
help: values.help ?? false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Args = ReturnType<typeof parse>;
|
|
61
|
+
|
|
62
|
+
async function collect(messages: AsyncIterable<Message>): Promise<Message[]> {
|
|
63
|
+
const all: Message[] = [];
|
|
64
|
+
for await (const m of messages) all.push(m);
|
|
65
|
+
return all.reverse();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function run(args: Args, out: (line: string) => void): Promise<void> {
|
|
69
|
+
const style = !args.json && process.stdout.isTTY && !process.env.NO_COLOR ? ansi : plain;
|
|
70
|
+
const format = args.json ? json : transcript(style);
|
|
71
|
+
const print = (m: Message) => out(format(m));
|
|
72
|
+
const config = await loadConfig();
|
|
73
|
+
const p = pigeon(config);
|
|
74
|
+
const { since, until, limit } = args;
|
|
75
|
+
|
|
76
|
+
switch (args.command) {
|
|
77
|
+
case "chats": {
|
|
78
|
+
const query = args.args.join(" ");
|
|
79
|
+
const chats = (query ? await p.findChats(query) : await p.chats()).slice(0, limit);
|
|
80
|
+
const width = Math.min(28, Math.max(0, ...chats.map((c) => c.name.length)));
|
|
81
|
+
for (const chat of chats) out(args.json ? json(chat) : chatLine(chat, style, width));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
case "read": {
|
|
85
|
+
const chat = args.args.join(" ") || args.chat || undefined;
|
|
86
|
+
(await collect(p.messages({ chat, since, until, limit }))).forEach(print);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case "search": {
|
|
90
|
+
const text = args.args.join(" ");
|
|
91
|
+
if (!text) throw new Error("search needs some words to look for");
|
|
92
|
+
(await collect(p.messages({ chat: args.chat, text, since, until, limit }))).forEach(print);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
case "watch": {
|
|
96
|
+
const chat = args.args.join(" ") || args.chat || undefined;
|
|
97
|
+
for await (const m of p.watch({ chat })) print(m);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case "send": {
|
|
101
|
+
const [chat, ...words] = args.args;
|
|
102
|
+
if (!chat) throw new Error("send needs a chat: pigeon send <chat> <text>");
|
|
103
|
+
const piped = words.length === 0 && args.files.length === 0 && !process.stdin.isTTY;
|
|
104
|
+
const text = piped ? (await Bun.stdin.text()).trimEnd() : words.join(" ");
|
|
105
|
+
await p.send(chat, { text, files: args.files });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "config": {
|
|
109
|
+
out(`config ${(await findConfig()) ?? "none"}`);
|
|
110
|
+
out(`sources ${p.sources.map((s) => s.label ?? s.id).join(", ")}`);
|
|
111
|
+
out(`filters ${config.filters?.length ?? 0}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
throw new Error(`unknown command "${args.command}", see pigeon --help`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function main(argv = Bun.argv.slice(2)): Promise<number> {
|
|
120
|
+
try {
|
|
121
|
+
const args = parse(argv);
|
|
122
|
+
if (args.help || !args.command) {
|
|
123
|
+
console.log(HELP);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
await run(args, (line) => console.log(line));
|
|
127
|
+
return 0;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error(`pigeon: ${err instanceof Error ? err.message : err}`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) process.exit(await main());
|