mac-imsg 0.1.0__tar.gz
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.
- mac_imsg-0.1.0/LICENSE +21 -0
- mac_imsg-0.1.0/PKG-INFO +162 -0
- mac_imsg-0.1.0/README.md +137 -0
- mac_imsg-0.1.0/imessage_mcp/__init__.py +13 -0
- mac_imsg-0.1.0/imessage_mcp/imessage.py +358 -0
- mac_imsg-0.1.0/imessage_mcp/main.py +120 -0
- mac_imsg-0.1.0/imessage_mcp/server.py +67 -0
- mac_imsg-0.1.0/imessage_mcp/ui.py +91 -0
- mac_imsg-0.1.0/imsgcore/__init__.py +1 -0
- mac_imsg-0.1.0/pyproject.toml +45 -0
- mac_imsg-0.1.0/rust/Cargo.lock +372 -0
- mac_imsg-0.1.0/rust/Cargo.toml +17 -0
- mac_imsg-0.1.0/rust/src/lib.rs +245 -0
mac_imsg-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ml-lubich
|
|
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.
|
mac_imsg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mac-imsg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Environment :: MacOS X
|
|
5
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Rust
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Requires-Dist: rich>=13.7
|
|
11
|
+
Requires-Dist: mcp[cli]>=1.6.0
|
|
12
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-cov>=5.0 ; extra == 'dev'
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Summary: Fast, local iMessage for your terminal + MCP — Rust-accelerated read/search, AppleScript send. macOS only.
|
|
17
|
+
Keywords: imessage,macos,mcp,cli,rust,applescript
|
|
18
|
+
Author: ml-lubich
|
|
19
|
+
License: MIT
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
22
|
+
Project-URL: Homepage, https://github.com/ml-lubich/imsg
|
|
23
|
+
Project-URL: Repository, https://github.com/ml-lubich/imsg
|
|
24
|
+
|
|
25
|
+
# imsg — fast, local iMessage for your terminal & MCP
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/imsg/)
|
|
28
|
+
[](https://pypi.org/project/imsg/)
|
|
29
|
+
[](rust/)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](#requirements)
|
|
32
|
+
|
|
33
|
+
Read, search, and send iMessage from your terminal — or expose it to Claude,
|
|
34
|
+
Cursor, VS Code, or any MCP client. Everything runs **locally on your Mac**:
|
|
35
|
+
no cloud, no login, no account. Reads open `~/Library/Messages/chat.db`
|
|
36
|
+
**read-only**; sends go through Messages.app.
|
|
37
|
+
|
|
38
|
+
**Why another one?** The hot path — decoding tens of thousands of
|
|
39
|
+
`attributedBody` typedstream blobs — is written in **Rust** (PyO3 + rusqlite),
|
|
40
|
+
so reads and searches over a large history are several times faster than the
|
|
41
|
+
pure-Python equivalent that other Messages servers use. Pure Python still ships
|
|
42
|
+
as a zero-dependency fallback, so it works even where the wheel doesn't.
|
|
43
|
+
|
|
44
|
+
<!-- BENCH -->
|
|
45
|
+
### Benchmark
|
|
46
|
+
|
|
47
|
+
Full-history search over a synthetic **50,000-message** database (70% stored as
|
|
48
|
+
`attributedBody` blobs), best-of-5, Apple Silicon (16 cores):
|
|
49
|
+
|
|
50
|
+
| operation | pure-Python | Rust core | speedup |
|
|
51
|
+
|---|---:|---:|---:|
|
|
52
|
+
| **full-history search** (decodes every message) | 120.9 ms | **35.7 ms** | **3.4×** |
|
|
53
|
+
| batched blob decode (allocation-bound) | 19.1 ms | 19.6 ms | 1.0× |
|
|
54
|
+
|
|
55
|
+
Two honest caveats, stated up front:
|
|
56
|
+
- Raw blob decoding is ~a wash — it's bound by allocating result strings, not
|
|
57
|
+
CPU, so native code doesn't help. The win is in **search**, where Rust
|
|
58
|
+
decodes and filters across all cores and returns only the matches.
|
|
59
|
+
- It's 3.4×, not 16×, because the SQLite read and result marshalling are serial
|
|
60
|
+
on both sides; only the decode+match parallelizes. The gap widens on larger
|
|
61
|
+
histories.
|
|
62
|
+
|
|
63
|
+
Reproduce: `python bench/benchmark.py`. **Bonus:** this search also finds
|
|
64
|
+
messages whose text lives in an `attributedBody` blob — which a plain
|
|
65
|
+
`text LIKE` query (used by Python-only Messages servers) silently misses.
|
|
66
|
+
<!-- /BENCH -->
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# with uv (recommended) — provisions Python + the prebuilt wheel
|
|
72
|
+
uv tool install imsg
|
|
73
|
+
|
|
74
|
+
# or pip
|
|
75
|
+
pip install imsg
|
|
76
|
+
|
|
77
|
+
# or Homebrew
|
|
78
|
+
brew install ml-lubich/tap/imsg
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- **macOS** (reads the local Messages database; sends via Messages.app).
|
|
84
|
+
- **Full Disk Access** for the app that runs `imsg` — Terminal/iTerm/Ghostty
|
|
85
|
+
for the CLI, or your MCP client (Claude Desktop, Cursor, VS Code…) for the
|
|
86
|
+
server. System Settings → Privacy & Security → Full Disk Access → add it,
|
|
87
|
+
then fully quit and reopen it.
|
|
88
|
+
- Messages.app signed in and able to send a normal message.
|
|
89
|
+
|
|
90
|
+
Run `imsg doctor` to check access and see which engine (Rust or Python) is live.
|
|
91
|
+
|
|
92
|
+
## CLI
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
imsg doctor # check Full Disk Access + engine
|
|
96
|
+
imsg chats # recent conversations + their ids
|
|
97
|
+
imsg contacts # handles (numbers / emails) seen
|
|
98
|
+
imsg read -c +14155551234 # recent messages with a contact
|
|
99
|
+
imsg read --chat 42 --limit 100 # a specific conversation
|
|
100
|
+
imsg search "dinner" # search message text
|
|
101
|
+
imsg send +14155551234 "on my way"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## MCP server
|
|
105
|
+
|
|
106
|
+
The `imsg-mcp` entry point speaks MCP over stdio. Add it to any client:
|
|
107
|
+
|
|
108
|
+
**Claude Code**
|
|
109
|
+
```bash
|
|
110
|
+
claude mcp add --transport stdio --scope user imsg -- imsg-mcp
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Claude Desktop / Cursor** (`mcpServers`) · **VS Code** (`servers`):
|
|
114
|
+
```json
|
|
115
|
+
{ "mcpServers": { "imsg": { "command": "imsg-mcp" } } }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Tools exposed: `check_access`, `get_recent_messages`, `search_messages`,
|
|
119
|
+
`list_chats`, `list_contacts`, `send_message` (the only one with a side effect).
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
There is no official iMessage API. Every tool in this space does the same two
|
|
124
|
+
local things; `imsg` just does the heavy half in Rust:
|
|
125
|
+
|
|
126
|
+
| Operation | Mechanism | Engine |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| read / search / list | `chat.db` (SQLite, read-only) + typedstream decode | **Rust** (`imsgcore`), Python fallback |
|
|
129
|
+
| send | AppleScript → Messages.app | Python (`osascript`) |
|
|
130
|
+
|
|
131
|
+
SQLite is the same C library everywhere, so the read speedup comes from doing
|
|
132
|
+
the per-message `attributedBody` decode and row marshalling natively instead of
|
|
133
|
+
in a Python loop. See [`bench/benchmark.py`](bench/benchmark.py) for the
|
|
134
|
+
methodology — both engines run identical queries over an identical synthetic
|
|
135
|
+
database, and the pure-Python column is the same algorithm Python-only servers
|
|
136
|
+
use.
|
|
137
|
+
|
|
138
|
+
## Privacy & security
|
|
139
|
+
|
|
140
|
+
- All database connections are opened **read-only** (`mode=ro`).
|
|
141
|
+
- Nothing is uploaded, mirrored, or indexed off-device.
|
|
142
|
+
- Sending is isolated in one function, escapes its AppleScript inputs, and is
|
|
143
|
+
the only operation that writes anything anywhere.
|
|
144
|
+
- Full Disk Access is broad — grant it only to apps you trust.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
git clone https://github.com/ml-lubich/imsg.git && cd imsg
|
|
150
|
+
uv venv && source .venv/bin/activate
|
|
151
|
+
uv pip install maturin
|
|
152
|
+
maturin develop # builds the Rust core + installs the package
|
|
153
|
+
uv pip install -e ".[dev]"
|
|
154
|
+
pytest # tests run on the pure-Python path (and Rust if built)
|
|
155
|
+
python bench/benchmark.py # regenerate the benchmark
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT © ml-lubich. Not affiliated with Apple. Use responsibly and only with
|
|
161
|
+
accounts and conversations you own.
|
|
162
|
+
|
mac_imsg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# imsg — fast, local iMessage for your terminal & MCP
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/imsg/)
|
|
4
|
+
[](https://pypi.org/project/imsg/)
|
|
5
|
+
[](rust/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](#requirements)
|
|
8
|
+
|
|
9
|
+
Read, search, and send iMessage from your terminal — or expose it to Claude,
|
|
10
|
+
Cursor, VS Code, or any MCP client. Everything runs **locally on your Mac**:
|
|
11
|
+
no cloud, no login, no account. Reads open `~/Library/Messages/chat.db`
|
|
12
|
+
**read-only**; sends go through Messages.app.
|
|
13
|
+
|
|
14
|
+
**Why another one?** The hot path — decoding tens of thousands of
|
|
15
|
+
`attributedBody` typedstream blobs — is written in **Rust** (PyO3 + rusqlite),
|
|
16
|
+
so reads and searches over a large history are several times faster than the
|
|
17
|
+
pure-Python equivalent that other Messages servers use. Pure Python still ships
|
|
18
|
+
as a zero-dependency fallback, so it works even where the wheel doesn't.
|
|
19
|
+
|
|
20
|
+
<!-- BENCH -->
|
|
21
|
+
### Benchmark
|
|
22
|
+
|
|
23
|
+
Full-history search over a synthetic **50,000-message** database (70% stored as
|
|
24
|
+
`attributedBody` blobs), best-of-5, Apple Silicon (16 cores):
|
|
25
|
+
|
|
26
|
+
| operation | pure-Python | Rust core | speedup |
|
|
27
|
+
|---|---:|---:|---:|
|
|
28
|
+
| **full-history search** (decodes every message) | 120.9 ms | **35.7 ms** | **3.4×** |
|
|
29
|
+
| batched blob decode (allocation-bound) | 19.1 ms | 19.6 ms | 1.0× |
|
|
30
|
+
|
|
31
|
+
Two honest caveats, stated up front:
|
|
32
|
+
- Raw blob decoding is ~a wash — it's bound by allocating result strings, not
|
|
33
|
+
CPU, so native code doesn't help. The win is in **search**, where Rust
|
|
34
|
+
decodes and filters across all cores and returns only the matches.
|
|
35
|
+
- It's 3.4×, not 16×, because the SQLite read and result marshalling are serial
|
|
36
|
+
on both sides; only the decode+match parallelizes. The gap widens on larger
|
|
37
|
+
histories.
|
|
38
|
+
|
|
39
|
+
Reproduce: `python bench/benchmark.py`. **Bonus:** this search also finds
|
|
40
|
+
messages whose text lives in an `attributedBody` blob — which a plain
|
|
41
|
+
`text LIKE` query (used by Python-only Messages servers) silently misses.
|
|
42
|
+
<!-- /BENCH -->
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# with uv (recommended) — provisions Python + the prebuilt wheel
|
|
48
|
+
uv tool install imsg
|
|
49
|
+
|
|
50
|
+
# or pip
|
|
51
|
+
pip install imsg
|
|
52
|
+
|
|
53
|
+
# or Homebrew
|
|
54
|
+
brew install ml-lubich/tap/imsg
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- **macOS** (reads the local Messages database; sends via Messages.app).
|
|
60
|
+
- **Full Disk Access** for the app that runs `imsg` — Terminal/iTerm/Ghostty
|
|
61
|
+
for the CLI, or your MCP client (Claude Desktop, Cursor, VS Code…) for the
|
|
62
|
+
server. System Settings → Privacy & Security → Full Disk Access → add it,
|
|
63
|
+
then fully quit and reopen it.
|
|
64
|
+
- Messages.app signed in and able to send a normal message.
|
|
65
|
+
|
|
66
|
+
Run `imsg doctor` to check access and see which engine (Rust or Python) is live.
|
|
67
|
+
|
|
68
|
+
## CLI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
imsg doctor # check Full Disk Access + engine
|
|
72
|
+
imsg chats # recent conversations + their ids
|
|
73
|
+
imsg contacts # handles (numbers / emails) seen
|
|
74
|
+
imsg read -c +14155551234 # recent messages with a contact
|
|
75
|
+
imsg read --chat 42 --limit 100 # a specific conversation
|
|
76
|
+
imsg search "dinner" # search message text
|
|
77
|
+
imsg send +14155551234 "on my way"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## MCP server
|
|
81
|
+
|
|
82
|
+
The `imsg-mcp` entry point speaks MCP over stdio. Add it to any client:
|
|
83
|
+
|
|
84
|
+
**Claude Code**
|
|
85
|
+
```bash
|
|
86
|
+
claude mcp add --transport stdio --scope user imsg -- imsg-mcp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Claude Desktop / Cursor** (`mcpServers`) · **VS Code** (`servers`):
|
|
90
|
+
```json
|
|
91
|
+
{ "mcpServers": { "imsg": { "command": "imsg-mcp" } } }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Tools exposed: `check_access`, `get_recent_messages`, `search_messages`,
|
|
95
|
+
`list_chats`, `list_contacts`, `send_message` (the only one with a side effect).
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
There is no official iMessage API. Every tool in this space does the same two
|
|
100
|
+
local things; `imsg` just does the heavy half in Rust:
|
|
101
|
+
|
|
102
|
+
| Operation | Mechanism | Engine |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| read / search / list | `chat.db` (SQLite, read-only) + typedstream decode | **Rust** (`imsgcore`), Python fallback |
|
|
105
|
+
| send | AppleScript → Messages.app | Python (`osascript`) |
|
|
106
|
+
|
|
107
|
+
SQLite is the same C library everywhere, so the read speedup comes from doing
|
|
108
|
+
the per-message `attributedBody` decode and row marshalling natively instead of
|
|
109
|
+
in a Python loop. See [`bench/benchmark.py`](bench/benchmark.py) for the
|
|
110
|
+
methodology — both engines run identical queries over an identical synthetic
|
|
111
|
+
database, and the pure-Python column is the same algorithm Python-only servers
|
|
112
|
+
use.
|
|
113
|
+
|
|
114
|
+
## Privacy & security
|
|
115
|
+
|
|
116
|
+
- All database connections are opened **read-only** (`mode=ro`).
|
|
117
|
+
- Nothing is uploaded, mirrored, or indexed off-device.
|
|
118
|
+
- Sending is isolated in one function, escapes its AppleScript inputs, and is
|
|
119
|
+
the only operation that writes anything anywhere.
|
|
120
|
+
- Full Disk Access is broad — grant it only to apps you trust.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/ml-lubich/imsg.git && cd imsg
|
|
126
|
+
uv venv && source .venv/bin/activate
|
|
127
|
+
uv pip install maturin
|
|
128
|
+
maturin develop # builds the Rust core + installs the package
|
|
129
|
+
uv pip install -e ".[dev]"
|
|
130
|
+
pytest # tests run on the pure-Python path (and Rust if built)
|
|
131
|
+
python bench/benchmark.py # regenerate the benchmark
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT © ml-lubich. Not affiliated with Apple. Use responsibly and only with
|
|
137
|
+
accounts and conversations you own.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""imsg: fast, local iMessage access (Rust-accelerated) with CLI + MCP server."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# True when the compiled Rust core (imsgcore) is importable; the pure-Python
|
|
6
|
+
# path in imessage.py is used otherwise. Exposed so `imsg doctor` / benchmarks
|
|
7
|
+
# can report which engine is active.
|
|
8
|
+
try: # pragma: no cover - presence depends on build
|
|
9
|
+
import imsgcore # noqa: F401
|
|
10
|
+
|
|
11
|
+
HAVE_RUST = True
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
HAVE_RUST = False
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Core iMessage access: read the local chat.db (read-only) and send via osascript.
|
|
2
|
+
|
|
3
|
+
No bridge or daemon is needed. Unlike WhatsApp (remote data behind a Go
|
|
4
|
+
bridge), iMessage data already lives locally in ~/Library/Messages/chat.db and
|
|
5
|
+
Messages.app can be driven with AppleScript. The CLI and the MCP server both
|
|
6
|
+
call the functions here directly.
|
|
7
|
+
|
|
8
|
+
Requirements at runtime:
|
|
9
|
+
- Full Disk Access for the process reading chat.db (System Settings ->
|
|
10
|
+
Privacy & Security -> Full Disk Access -> add your terminal / the app).
|
|
11
|
+
- Automation permission for Messages.app (granted on first send).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable
|
|
21
|
+
|
|
22
|
+
# Apple's Core Data epoch: 2001-01-01 UTC, in unix seconds.
|
|
23
|
+
_APPLE_EPOCH = 978307200
|
|
24
|
+
|
|
25
|
+
DEFAULT_DB = Path.home() / "Library" / "Messages" / "chat.db"
|
|
26
|
+
|
|
27
|
+
try: # Rust-accelerated core; falls back to pure Python when absent.
|
|
28
|
+
import imsgcore as _RUST
|
|
29
|
+
except ImportError: # pragma: no cover - depends on build
|
|
30
|
+
_RUST = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AccessError(RuntimeError):
|
|
34
|
+
"""chat.db exists but cannot be opened (missing Full Disk Access)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def db_path() -> Path:
|
|
38
|
+
"""The chat.db path, overridable via IMESSAGE_DB (handy for tests)."""
|
|
39
|
+
return Path(os.environ.get("IMESSAGE_DB", str(DEFAULT_DB)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _connect(path: Path | None = None):
|
|
43
|
+
import sqlite3
|
|
44
|
+
|
|
45
|
+
p = path or db_path()
|
|
46
|
+
if not p.exists():
|
|
47
|
+
raise AccessError(f"chat.db not found at {p}")
|
|
48
|
+
try:
|
|
49
|
+
return sqlite3.connect(f"file:{p}?mode=ro", uri=True)
|
|
50
|
+
except sqlite3.OperationalError as exc: # pragma: no cover - env specific
|
|
51
|
+
raise AccessError(
|
|
52
|
+
f"cannot open {p}: {exc}. Grant Full Disk Access to this terminal "
|
|
53
|
+
"in System Settings -> Privacy & Security -> Full Disk Access."
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _via_rust(thunk):
|
|
58
|
+
"""Run a Rust core call, translating its RuntimeError (e.g. an unopenable
|
|
59
|
+
chat.db from missing Full Disk Access) into AccessError so the CLI/MCP
|
|
60
|
+
handle it the same way as the pure-Python path."""
|
|
61
|
+
try:
|
|
62
|
+
return thunk()
|
|
63
|
+
except RuntimeError as exc:
|
|
64
|
+
raise AccessError(
|
|
65
|
+
f"cannot open {db_path()}: {exc}. Grant Full Disk Access to this "
|
|
66
|
+
"terminal in System Settings -> Privacy & Security -> Full Disk "
|
|
67
|
+
"Access, then fully quit and reopen it."
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apple_time_to_iso(raw: int | None) -> str | None:
|
|
72
|
+
"""Convert a chat.db `date` value to an ISO-8601 UTC string.
|
|
73
|
+
|
|
74
|
+
Modern macOS stores nanoseconds since the Apple epoch; older stores
|
|
75
|
+
seconds. Disambiguate by magnitude (ns values are ~1e18).
|
|
76
|
+
"""
|
|
77
|
+
if not raw:
|
|
78
|
+
return None
|
|
79
|
+
seconds = raw / 1_000_000_000 if raw > 1_000_000_000_000 else raw
|
|
80
|
+
ts = seconds + _APPLE_EPOCH
|
|
81
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decode_attributed_body(blob: bytes | None) -> str | None:
|
|
85
|
+
"""Best-effort text extraction from a message's attributedBody blob.
|
|
86
|
+
|
|
87
|
+
Newer messages leave `text` NULL and stash the body in a typedstream
|
|
88
|
+
archive. This scans for the NSString payload and reads its length-prefixed
|
|
89
|
+
UTF-8 content — the widely-used heuristic.
|
|
90
|
+
|
|
91
|
+
ponytail: byte-scan heuristic, not a full typedstream parser. Handles the
|
|
92
|
+
common single-run case; upgrade to `pip install pytypedstream` if rich
|
|
93
|
+
multi-run attributed strings start losing text.
|
|
94
|
+
"""
|
|
95
|
+
if not blob or b"NSString" not in blob:
|
|
96
|
+
return None
|
|
97
|
+
data = blob.split(b"NSString", 1)[1]
|
|
98
|
+
# Skip the class-version bytes that precede the length marker.
|
|
99
|
+
data = data[5:]
|
|
100
|
+
if not data:
|
|
101
|
+
return None
|
|
102
|
+
marker = data[0]
|
|
103
|
+
if marker == 0x81: # 2-byte little-endian length
|
|
104
|
+
if len(data) < 3:
|
|
105
|
+
return None
|
|
106
|
+
length = int.from_bytes(data[1:3], "little")
|
|
107
|
+
start = 3
|
|
108
|
+
else: # single-byte length
|
|
109
|
+
length = marker
|
|
110
|
+
start = 1
|
|
111
|
+
text = data[start : start + length].decode("utf-8", errors="replace")
|
|
112
|
+
return text or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Chat:
|
|
117
|
+
chat_id: int
|
|
118
|
+
identifier: str
|
|
119
|
+
name: str | None
|
|
120
|
+
service: str | None
|
|
121
|
+
|
|
122
|
+
def dict(self) -> dict[str, Any]:
|
|
123
|
+
return asdict(self)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Message:
|
|
128
|
+
text: str | None
|
|
129
|
+
sender: str # "me" or a handle id (phone/email)
|
|
130
|
+
is_from_me: bool
|
|
131
|
+
date: str | None
|
|
132
|
+
service: str | None
|
|
133
|
+
has_attachment: bool
|
|
134
|
+
|
|
135
|
+
def dict(self) -> dict[str, Any]:
|
|
136
|
+
return asdict(self)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Contact:
|
|
141
|
+
handle: str # phone number or email
|
|
142
|
+
service: str | None
|
|
143
|
+
|
|
144
|
+
def dict(self) -> dict[str, Any]:
|
|
145
|
+
return asdict(self)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rows(cur) -> Iterable[tuple]:
|
|
149
|
+
while True:
|
|
150
|
+
row = cur.fetchone()
|
|
151
|
+
if row is None:
|
|
152
|
+
return
|
|
153
|
+
yield row
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def list_chats(limit: int = 20, path: Path | None = None) -> list[Chat]:
|
|
157
|
+
"""Recent conversations, most-recently-active first."""
|
|
158
|
+
if _RUST is not None:
|
|
159
|
+
rows = _via_rust(lambda: _RUST.list_chats(str(path or db_path()), limit))
|
|
160
|
+
return [Chat(*r) for r in rows]
|
|
161
|
+
conn = _connect(path)
|
|
162
|
+
try:
|
|
163
|
+
cur = conn.execute(
|
|
164
|
+
"""
|
|
165
|
+
SELECT c.ROWID, c.chat_identifier, c.display_name, c.service_name,
|
|
166
|
+
MAX(m.date) AS last
|
|
167
|
+
FROM chat c
|
|
168
|
+
JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID
|
|
169
|
+
JOIN message m ON m.ROWID = cmj.message_id
|
|
170
|
+
GROUP BY c.ROWID
|
|
171
|
+
ORDER BY last DESC
|
|
172
|
+
LIMIT ?
|
|
173
|
+
""",
|
|
174
|
+
(limit,),
|
|
175
|
+
)
|
|
176
|
+
return [Chat(r[0], r[1], r[2], r[3]) for r in _rows(cur)]
|
|
177
|
+
finally:
|
|
178
|
+
conn.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def list_contacts(limit: int = 100, path: Path | None = None) -> list[Contact]:
|
|
182
|
+
"""Distinct handles (phone numbers / emails) seen in the message store."""
|
|
183
|
+
if _RUST is not None:
|
|
184
|
+
rows = _via_rust(lambda: _RUST.list_contacts(str(path or db_path()), limit))
|
|
185
|
+
return [Contact(*r) for r in rows]
|
|
186
|
+
conn = _connect(path)
|
|
187
|
+
try:
|
|
188
|
+
cur = conn.execute(
|
|
189
|
+
"SELECT DISTINCT id, service FROM handle ORDER BY id LIMIT ?",
|
|
190
|
+
(limit,),
|
|
191
|
+
)
|
|
192
|
+
return [Contact(r[0], r[1]) for r in _rows(cur)]
|
|
193
|
+
finally:
|
|
194
|
+
conn.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _message_from_rust(t: tuple) -> Message:
|
|
198
|
+
"""Wrap a tuple from imsgcore into a Message.
|
|
199
|
+
|
|
200
|
+
Rust returns (text|None, is_from_me, sender, date_raw, service, has_attach)
|
|
201
|
+
with text already resolved (attributedBody decoded in Rust).
|
|
202
|
+
"""
|
|
203
|
+
text, is_from_me, sender, date_raw, service, has_attach = t
|
|
204
|
+
return Message(
|
|
205
|
+
text=text,
|
|
206
|
+
sender=sender,
|
|
207
|
+
is_from_me=bool(is_from_me),
|
|
208
|
+
date=_apple_time_to_iso(date_raw),
|
|
209
|
+
service=service,
|
|
210
|
+
has_attachment=bool(has_attach),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _message_from_row(row: tuple) -> Message:
|
|
215
|
+
text, body, is_from_me, handle, date, service, has_attach = row
|
|
216
|
+
resolved = text or _decode_attributed_body(body)
|
|
217
|
+
return Message(
|
|
218
|
+
text=resolved,
|
|
219
|
+
sender="me" if is_from_me else (handle or "unknown"),
|
|
220
|
+
is_from_me=bool(is_from_me),
|
|
221
|
+
date=_apple_time_to_iso(date),
|
|
222
|
+
service=service,
|
|
223
|
+
has_attachment=bool(has_attach),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_MESSAGE_COLS = (
|
|
228
|
+
"m.text, m.attributedBody, m.is_from_me, h.id, m.date, m.service, "
|
|
229
|
+
"m.cache_has_attachments"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def read_messages(
|
|
234
|
+
contact: str | None = None,
|
|
235
|
+
chat_id: int | None = None,
|
|
236
|
+
limit: int = 20,
|
|
237
|
+
path: Path | None = None,
|
|
238
|
+
) -> list[Message]:
|
|
239
|
+
"""Most recent messages, optionally filtered by contact handle or chat_id.
|
|
240
|
+
|
|
241
|
+
Returned oldest-first (chronological) for readable transcripts.
|
|
242
|
+
"""
|
|
243
|
+
if _RUST is not None:
|
|
244
|
+
rows = _via_rust(lambda: _RUST.read_messages(str(path or db_path()), contact, chat_id, limit))
|
|
245
|
+
return list(reversed([_message_from_rust(r) for r in rows]))
|
|
246
|
+
conn = _connect(path)
|
|
247
|
+
try:
|
|
248
|
+
where = []
|
|
249
|
+
params: list[Any] = []
|
|
250
|
+
if chat_id is not None:
|
|
251
|
+
where.append(
|
|
252
|
+
"m.ROWID IN (SELECT message_id FROM chat_message_join WHERE chat_id = ?)"
|
|
253
|
+
)
|
|
254
|
+
params.append(chat_id)
|
|
255
|
+
if contact:
|
|
256
|
+
where.append("h.id = ?")
|
|
257
|
+
params.append(contact)
|
|
258
|
+
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
|
259
|
+
params.append(limit)
|
|
260
|
+
cur = conn.execute(
|
|
261
|
+
f"""
|
|
262
|
+
SELECT {_MESSAGE_COLS}
|
|
263
|
+
FROM message m
|
|
264
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
265
|
+
{clause}
|
|
266
|
+
ORDER BY m.date DESC
|
|
267
|
+
LIMIT ?
|
|
268
|
+
""",
|
|
269
|
+
params,
|
|
270
|
+
)
|
|
271
|
+
msgs = [_message_from_row(r) for r in _rows(cur)]
|
|
272
|
+
return list(reversed(msgs))
|
|
273
|
+
finally:
|
|
274
|
+
conn.close()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def search_messages(query: str, limit: int = 20, path: Path | None = None) -> list[Message]:
|
|
278
|
+
"""Full-text-ish LIKE search over message bodies, newest first."""
|
|
279
|
+
if _RUST is not None:
|
|
280
|
+
rows = _via_rust(lambda: _RUST.search_messages(str(path or db_path()), query, limit))
|
|
281
|
+
return [_message_from_rust(r) for r in rows]
|
|
282
|
+
conn = _connect(path)
|
|
283
|
+
try:
|
|
284
|
+
cur = conn.execute(
|
|
285
|
+
f"""
|
|
286
|
+
SELECT {_MESSAGE_COLS}
|
|
287
|
+
FROM message m
|
|
288
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
289
|
+
WHERE m.text LIKE ?
|
|
290
|
+
ORDER BY m.date DESC
|
|
291
|
+
LIMIT ?
|
|
292
|
+
""",
|
|
293
|
+
(f"%{query}%", limit),
|
|
294
|
+
)
|
|
295
|
+
return [_message_from_row(r) for r in _rows(cur)]
|
|
296
|
+
finally:
|
|
297
|
+
conn.close()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def search_all(query: str, limit: int = 100, path: Path | None = None) -> list[Message]:
|
|
301
|
+
"""Search the FULL history, including messages stored as attributedBody
|
|
302
|
+
blobs that a plain `text LIKE` query silently misses.
|
|
303
|
+
|
|
304
|
+
Rust decodes and matches every message in parallel across cores; the
|
|
305
|
+
pure-Python fallback below does the same single-threaded (and is the honest
|
|
306
|
+
baseline the benchmark compares against).
|
|
307
|
+
"""
|
|
308
|
+
if _RUST is not None:
|
|
309
|
+
rows = _via_rust(lambda: _RUST.search_all(str(path or db_path()), query, limit))
|
|
310
|
+
return [_message_from_rust(r) for r in rows]
|
|
311
|
+
|
|
312
|
+
needle = query.lower()
|
|
313
|
+
conn = _connect(path)
|
|
314
|
+
try:
|
|
315
|
+
cur = conn.execute(
|
|
316
|
+
f"SELECT {_MESSAGE_COLS} FROM message m "
|
|
317
|
+
"LEFT JOIN handle h ON h.ROWID = m.handle_id ORDER BY m.date DESC"
|
|
318
|
+
)
|
|
319
|
+
out: list[Message] = []
|
|
320
|
+
for row in _rows(cur):
|
|
321
|
+
msg = _message_from_row(row)
|
|
322
|
+
if msg.text and needle in msg.text.lower():
|
|
323
|
+
out.append(msg)
|
|
324
|
+
if len(out) >= limit:
|
|
325
|
+
break
|
|
326
|
+
return out
|
|
327
|
+
finally:
|
|
328
|
+
conn.close()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _osascript(script: str) -> None:
|
|
332
|
+
proc = subprocess.run(
|
|
333
|
+
["osascript", "-e", script],
|
|
334
|
+
capture_output=True,
|
|
335
|
+
text=True,
|
|
336
|
+
)
|
|
337
|
+
if proc.returncode != 0:
|
|
338
|
+
raise RuntimeError(proc.stderr.strip() or "osascript failed")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def send_message(recipient: str, text: str) -> None:
|
|
342
|
+
"""Send an iMessage/SMS to a handle (phone number or email) via Messages.app.
|
|
343
|
+
|
|
344
|
+
Tries the iMessage service first; Messages falls back to SMS for handles
|
|
345
|
+
that aren't on iMessage when the account allows it.
|
|
346
|
+
"""
|
|
347
|
+
if not recipient or not text:
|
|
348
|
+
raise ValueError("recipient and text are required")
|
|
349
|
+
safe_text = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
350
|
+
safe_recip = recipient.replace("\\", "\\\\").replace('"', '\\"')
|
|
351
|
+
script = f'''
|
|
352
|
+
tell application "Messages"
|
|
353
|
+
set targetService to 1st account whose service type = iMessage
|
|
354
|
+
set targetBuddy to participant "{safe_recip}" of targetService
|
|
355
|
+
send "{safe_text}" to targetBuddy
|
|
356
|
+
end tell
|
|
357
|
+
'''
|
|
358
|
+
_osascript(script)
|