imessage-chatdb 0.1.0__py3-none-any.whl
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.
- imessage_chatdb/__init__.py +123 -0
- imessage_chatdb/__main__.py +355 -0
- imessage_chatdb/attachments.py +215 -0
- imessage_chatdb/chats.py +377 -0
- imessage_chatdb/connection.py +129 -0
- imessage_chatdb/dates.py +58 -0
- imessage_chatdb/db.py +346 -0
- imessage_chatdb/errors.py +84 -0
- imessage_chatdb/handles.py +54 -0
- imessage_chatdb/keyed_archive.py +118 -0
- imessage_chatdb/link_preview.py +190 -0
- imessage_chatdb/messages.py +366 -0
- imessage_chatdb/models.py +452 -0
- imessage_chatdb/polling.py +241 -0
- imessage_chatdb/py.typed +0 -0
- imessage_chatdb/reactions.py +102 -0
- imessage_chatdb/schema.py +315 -0
- imessage_chatdb/search.py +125 -0
- imessage_chatdb/services.py +54 -0
- imessage_chatdb/sql.py +197 -0
- imessage_chatdb/typedstream.py +111 -0
- imessage_chatdb-0.1.0.dist-info/METADATA +651 -0
- imessage_chatdb-0.1.0.dist-info/RECORD +25 -0
- imessage_chatdb-0.1.0.dist-info/WHEEL +4 -0
- imessage_chatdb-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""imessage-chatdb: a read-only, stdlib-only reader for Apple Messages ``chat.db``.
|
|
2
|
+
|
|
3
|
+
The public surface of DESIGN.md section 4: :func:`open` / :class:`ChatDB`
|
|
4
|
+
(section 4.1, 4.5), the schema (4.2), the pure functions and constants (4.3),
|
|
5
|
+
the models (5), the errors (6.6) and the polling primitives (4.6). The query
|
|
6
|
+
functions that take a connection (section 4.4) live on their submodules
|
|
7
|
+
(``imessage_chatdb.messages``, ``.attachments``, ``.chats``, ``.search``) and
|
|
8
|
+
behind the same names on :class:`ChatDB`.
|
|
9
|
+
|
|
10
|
+
One name is deliberately kept off ``__all__``: ``imessage_chatdb.open``
|
|
11
|
+
exists (``imessage_chatdb.open(path)``) but is not exported, so
|
|
12
|
+
``from imessage_chatdb import *`` never shadows the builtin. The polling
|
|
13
|
+
primitives live in ``imessage_chatdb.polling`` and are re-exported here, so
|
|
14
|
+
``imessage_chatdb.watch`` *is* the ``watch()`` generator (also reachable as
|
|
15
|
+
:meth:`ChatDB.watch`).
|
|
16
|
+
|
|
17
|
+
Nothing here opens a database at import time.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .connection import DEFAULT_CHATDB, open_connection
|
|
23
|
+
from .dates import APPLE_EPOCH_OFFSET, apple_to_datetime, apple_to_unix, unix_to_apple
|
|
24
|
+
from .db import ChatDB
|
|
25
|
+
from .db import open as open
|
|
26
|
+
from .errors import ChatDBAccessError, ChatDBBusy, ChatDBError, CursorAhead, SchemaError
|
|
27
|
+
from .handles import address_key, is_email, is_group_style
|
|
28
|
+
from .keyed_archive import KeyedArchive
|
|
29
|
+
from .link_preview import (
|
|
30
|
+
LINK_BALLOON,
|
|
31
|
+
SKIP_IMG,
|
|
32
|
+
LinkPreview,
|
|
33
|
+
embedded_image,
|
|
34
|
+
embedded_images,
|
|
35
|
+
parse_link_preview,
|
|
36
|
+
sniff_image_mime,
|
|
37
|
+
)
|
|
38
|
+
from .models import (
|
|
39
|
+
Attachment,
|
|
40
|
+
Chat,
|
|
41
|
+
ChatMatch,
|
|
42
|
+
ChatSummary,
|
|
43
|
+
LiteMessage,
|
|
44
|
+
Message,
|
|
45
|
+
ReplyTarget,
|
|
46
|
+
SearchHit,
|
|
47
|
+
)
|
|
48
|
+
from .polling import Cursor, Event, poll_once, run_watch, watch
|
|
49
|
+
from .reactions import Reaction, classify_reaction, parse_associated_guid
|
|
50
|
+
from .schema import OPTIONAL, REQUIRED, Schema, build_message_select
|
|
51
|
+
from .search import extract_urls, snippet
|
|
52
|
+
from .services import Service, normalize_service, service_family
|
|
53
|
+
from .typedstream import clean_text, effective_text, extract_text
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"__version__",
|
|
59
|
+
# errors
|
|
60
|
+
"ChatDBError",
|
|
61
|
+
"ChatDBAccessError",
|
|
62
|
+
"ChatDBBusy",
|
|
63
|
+
"CursorAhead",
|
|
64
|
+
"SchemaError",
|
|
65
|
+
# opening (``open`` is an attribute, not an export: see the module docstring)
|
|
66
|
+
"DEFAULT_CHATDB",
|
|
67
|
+
"open_connection",
|
|
68
|
+
"ChatDB",
|
|
69
|
+
# schema
|
|
70
|
+
"Schema",
|
|
71
|
+
"REQUIRED",
|
|
72
|
+
"OPTIONAL",
|
|
73
|
+
"build_message_select",
|
|
74
|
+
# dates
|
|
75
|
+
"APPLE_EPOCH_OFFSET",
|
|
76
|
+
"apple_to_unix",
|
|
77
|
+
"unix_to_apple",
|
|
78
|
+
"apple_to_datetime",
|
|
79
|
+
# typedstream
|
|
80
|
+
"extract_text",
|
|
81
|
+
"effective_text",
|
|
82
|
+
"clean_text",
|
|
83
|
+
# keyed archive
|
|
84
|
+
"KeyedArchive",
|
|
85
|
+
# link preview
|
|
86
|
+
"LINK_BALLOON",
|
|
87
|
+
"SKIP_IMG",
|
|
88
|
+
"LinkPreview",
|
|
89
|
+
"parse_link_preview",
|
|
90
|
+
"embedded_images",
|
|
91
|
+
"embedded_image",
|
|
92
|
+
"sniff_image_mime",
|
|
93
|
+
# reactions
|
|
94
|
+
"Reaction",
|
|
95
|
+
"classify_reaction",
|
|
96
|
+
"parse_associated_guid",
|
|
97
|
+
# services
|
|
98
|
+
"Service",
|
|
99
|
+
"normalize_service",
|
|
100
|
+
"service_family",
|
|
101
|
+
# handles
|
|
102
|
+
"address_key",
|
|
103
|
+
"is_email",
|
|
104
|
+
"is_group_style",
|
|
105
|
+
# search helpers
|
|
106
|
+
"extract_urls",
|
|
107
|
+
"snippet",
|
|
108
|
+
# models
|
|
109
|
+
"Message",
|
|
110
|
+
"Attachment",
|
|
111
|
+
"ReplyTarget",
|
|
112
|
+
"LiteMessage",
|
|
113
|
+
"Chat",
|
|
114
|
+
"ChatSummary",
|
|
115
|
+
"ChatMatch",
|
|
116
|
+
"SearchHit",
|
|
117
|
+
# polling (``imessage_chatdb.polling``)
|
|
118
|
+
"Cursor",
|
|
119
|
+
"Event",
|
|
120
|
+
"poll_once",
|
|
121
|
+
"watch",
|
|
122
|
+
"run_watch",
|
|
123
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""``python -m imessage_chatdb`` - a small JSON-lines command line (DESIGN.md section 4.7).
|
|
2
|
+
|
|
3
|
+
Subcommands::
|
|
4
|
+
|
|
5
|
+
check open the database, print profile + max ROWID + "Full Disk Access OK"
|
|
6
|
+
tail [--since-rowid N] [--follow] [--limit N] [--interval S]
|
|
7
|
+
chats [--limit N]
|
|
8
|
+
search TEXT [--limit N]
|
|
9
|
+
|
|
10
|
+
Every subcommand takes ``--db PATH`` (default ``~/Library/Messages/chat.db``).
|
|
11
|
+
``tail``, ``chats`` and ``search`` print one JSON object per line from
|
|
12
|
+
``Message.to_dict()`` / ``ChatSummary.to_dict()`` / ``SearchHit.to_dict()``;
|
|
13
|
+
``check`` prints one JSON object describing the database.
|
|
14
|
+
|
|
15
|
+
Privacy: ``tail``, ``chats`` and ``search`` print message content (text,
|
|
16
|
+
handles, chat names) to stdout as JSON lines, so do not point them at a log
|
|
17
|
+
file or a remote pipe; the library itself never logs or prints message
|
|
18
|
+
content. ``check`` prints only the path, profile and max ROWID.
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 success; 1 any other ``ChatDBError`` (busy, schema); 2 the
|
|
21
|
+
database could not be opened (``ChatDBAccessError``: the message, which names
|
|
22
|
+
the path and the interpreter binary that needs Full Disk Access, goes to
|
|
23
|
+
stderr); 64 (``EX_USAGE``) a command-line usage error (unknown subcommand, bad
|
|
24
|
+
flag or value -- argparse's default of 2 is *not* used, so a health probe that
|
|
25
|
+
treats 2 as "Full Disk Access lost" cannot misfire on a typo); 130 interrupted.
|
|
26
|
+
``--help`` exits 0. A stdout closed early by its reader (``tail ... | head
|
|
27
|
+
-1``) is not an error: output stops, nothing is printed about the broken pipe
|
|
28
|
+
(stdout is pointed at ``os.devnull`` so the interpreter's shutdown flush cannot
|
|
29
|
+
fail again) and the exit code is 0.
|
|
30
|
+
|
|
31
|
+
The CLI is built on the module-level query functions rather than the
|
|
32
|
+
``ChatDB`` facade so it stays a thin, dependency-free layer; it opens one
|
|
33
|
+
connection per command (and one per polling round in ``--follow`` mode),
|
|
34
|
+
exactly as the facade does.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sqlite3
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
from collections.abc import Callable, Sequence
|
|
46
|
+
from typing import IO, Any, NoReturn
|
|
47
|
+
|
|
48
|
+
from .chats import chats_by_activity
|
|
49
|
+
from .connection import DEFAULT_CHATDB, open_connection
|
|
50
|
+
from .errors import ChatDBAccessError, ChatDBBusy, ChatDBError
|
|
51
|
+
from .messages import max_rowid, messages_after
|
|
52
|
+
from .schema import Schema
|
|
53
|
+
from .search import search as search_hits
|
|
54
|
+
|
|
55
|
+
__all__ = ["UsageError", "build_parser", "main"]
|
|
56
|
+
|
|
57
|
+
EXIT_OK = 0
|
|
58
|
+
EXIT_ERROR = 1
|
|
59
|
+
EXIT_ACCESS = 2
|
|
60
|
+
EXIT_USAGE = 64 # sysexits.h EX_USAGE; distinct from EXIT_ACCESS on purpose
|
|
61
|
+
EXIT_INTERRUPTED = 130
|
|
62
|
+
|
|
63
|
+
DEFAULT_CHATS_LIMIT = 200
|
|
64
|
+
DEFAULT_SEARCH_LIMIT = 30
|
|
65
|
+
DEFAULT_FOLLOW_INTERVAL = 2.0
|
|
66
|
+
|
|
67
|
+
#: Shown by ``--help`` (main, ``tail``, ``chats`` and ``search``): the only
|
|
68
|
+
#: place the library prints message content (``chats`` prints handles and
|
|
69
|
+
#: chat names, which count).
|
|
70
|
+
PRIVACY_NOTE = (
|
|
71
|
+
"privacy: tail, chats and search print message content (text, handles, chat names) "
|
|
72
|
+
"to stdout as JSON lines; do not point them at a log file or a remote pipe."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UsageError(SystemExit):
|
|
77
|
+
"""A command-line usage error; ``code`` is :data:`EXIT_USAGE`.
|
|
78
|
+
|
|
79
|
+
Raised by the parser :func:`build_parser` returns in place of argparse's
|
|
80
|
+
``SystemExit(2)`` so that exit code 2 stays reserved for "cannot open the
|
|
81
|
+
database". ``usage`` is the parser's usage line and ``message`` the
|
|
82
|
+
explanation; :func:`main` writes both to its ``stderr`` stream.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, usage: str, message: str) -> None:
|
|
86
|
+
super().__init__(EXIT_USAGE)
|
|
87
|
+
self.usage = usage
|
|
88
|
+
self.message = message
|
|
89
|
+
|
|
90
|
+
def __str__(self) -> str:
|
|
91
|
+
return self.message
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Parser(argparse.ArgumentParser):
|
|
95
|
+
"""``ArgumentParser`` whose errors raise :class:`UsageError` (code 64, not 2).
|
|
96
|
+
|
|
97
|
+
Subparsers are created with ``parser_class=type(self)`` by argparse, so
|
|
98
|
+
every subcommand's errors take the same path.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def error(self, message: str) -> NoReturn:
|
|
102
|
+
raise UsageError(self.format_usage(), f"{self.prog}: error: {message}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
106
|
+
"""The argparse parser; ``--db`` is accepted after every subcommand.
|
|
107
|
+
|
|
108
|
+
Usage errors raise :class:`UsageError` (a ``SystemExit`` with code 64)
|
|
109
|
+
instead of exiting with argparse's 2; ``--help`` still exits 0.
|
|
110
|
+
"""
|
|
111
|
+
common = _Parser(add_help=False) # same class so subparsers accept it as a parent
|
|
112
|
+
common.add_argument(
|
|
113
|
+
"--db",
|
|
114
|
+
default=DEFAULT_CHATDB,
|
|
115
|
+
metavar="PATH",
|
|
116
|
+
help=f"path to chat.db (default: {DEFAULT_CHATDB})",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
parser = _Parser(
|
|
120
|
+
prog="python -m imessage_chatdb",
|
|
121
|
+
description="Read-only JSON-lines access to an Apple Messages chat.db.",
|
|
122
|
+
epilog=PRIVACY_NOTE,
|
|
123
|
+
)
|
|
124
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
125
|
+
|
|
126
|
+
sub.add_parser(
|
|
127
|
+
"check",
|
|
128
|
+
parents=[common],
|
|
129
|
+
help="open the database and report the schema profile and max ROWID",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
tail = sub.add_parser(
|
|
133
|
+
"tail",
|
|
134
|
+
parents=[common],
|
|
135
|
+
help="print messages with ROWID > N (default: start at now, like watch())",
|
|
136
|
+
epilog=PRIVACY_NOTE,
|
|
137
|
+
)
|
|
138
|
+
tail.add_argument(
|
|
139
|
+
"--since-rowid",
|
|
140
|
+
type=int,
|
|
141
|
+
default=None,
|
|
142
|
+
metavar="N",
|
|
143
|
+
help="print messages with ROWID > N; omitted = start at MAX(ROWID) (no replay)",
|
|
144
|
+
)
|
|
145
|
+
tail.add_argument(
|
|
146
|
+
"--follow",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="keep polling for new messages until interrupted",
|
|
149
|
+
)
|
|
150
|
+
tail.add_argument(
|
|
151
|
+
"--limit",
|
|
152
|
+
type=int,
|
|
153
|
+
default=None,
|
|
154
|
+
metavar="N",
|
|
155
|
+
help="cap the number of messages printed in one round",
|
|
156
|
+
)
|
|
157
|
+
tail.add_argument(
|
|
158
|
+
"--interval",
|
|
159
|
+
type=float,
|
|
160
|
+
default=DEFAULT_FOLLOW_INTERVAL,
|
|
161
|
+
metavar="SECONDS",
|
|
162
|
+
help=f"seconds between polls with --follow (default: {DEFAULT_FOLLOW_INTERVAL})",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
chats = sub.add_parser(
|
|
166
|
+
"chats",
|
|
167
|
+
parents=[common],
|
|
168
|
+
help="list chats, most recent activity first",
|
|
169
|
+
epilog=PRIVACY_NOTE,
|
|
170
|
+
)
|
|
171
|
+
chats.add_argument(
|
|
172
|
+
"--limit",
|
|
173
|
+
type=int,
|
|
174
|
+
default=DEFAULT_CHATS_LIMIT,
|
|
175
|
+
metavar="N",
|
|
176
|
+
help=f"maximum number of chats (default: {DEFAULT_CHATS_LIMIT})",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
srch = sub.add_parser(
|
|
180
|
+
"search", parents=[common], help="search message text, newest first", epilog=PRIVACY_NOTE
|
|
181
|
+
)
|
|
182
|
+
srch.add_argument("text", metavar="TEXT", help="text to search for (case-insensitive)")
|
|
183
|
+
srch.add_argument(
|
|
184
|
+
"--limit",
|
|
185
|
+
type=int,
|
|
186
|
+
default=DEFAULT_SEARCH_LIMIT,
|
|
187
|
+
metavar="N",
|
|
188
|
+
help=f"maximum number of hits (default: {DEFAULT_SEARCH_LIMIT})",
|
|
189
|
+
)
|
|
190
|
+
return parser
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _emit(out: IO[str], obj: dict[str, Any]) -> None:
|
|
194
|
+
out.write(json.dumps(obj, ensure_ascii=False))
|
|
195
|
+
out.write("\n")
|
|
196
|
+
out.flush()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _discard_further_output(out: IO[str]) -> None:
|
|
200
|
+
"""After a ``BrokenPipeError`` on ``out``, point its descriptor at ``os.devnull``.
|
|
201
|
+
|
|
202
|
+
The reader of our stdout went away (``| head -1``). Python flushes the
|
|
203
|
+
standard streams again at interpreter shutdown, which would raise a second
|
|
204
|
+
``BrokenPipeError`` ("Exception ignored in ...") and turn the exit code
|
|
205
|
+
into 120; redirecting the descriptor first is the recipe from the
|
|
206
|
+
``signal`` module documentation. Only the stream that actually broke is
|
|
207
|
+
touched: an injected ``stdout`` without a descriptor (a test double, an
|
|
208
|
+
``io.StringIO``) leaves the process's real stdout alone.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
fd = out.fileno()
|
|
212
|
+
except (AttributeError, OSError, ValueError): # io.UnsupportedOperation is both
|
|
213
|
+
return
|
|
214
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
215
|
+
try:
|
|
216
|
+
os.dup2(devnull, fd)
|
|
217
|
+
finally:
|
|
218
|
+
os.close(devnull)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cmd_check(args: argparse.Namespace, out: IO[str]) -> int:
|
|
222
|
+
conn = open_connection(args.db)
|
|
223
|
+
try:
|
|
224
|
+
schema = Schema(conn)
|
|
225
|
+
schema.check()
|
|
226
|
+
top = max_rowid(conn)
|
|
227
|
+
finally:
|
|
228
|
+
conn.close()
|
|
229
|
+
_emit(
|
|
230
|
+
out,
|
|
231
|
+
{
|
|
232
|
+
"ok": True,
|
|
233
|
+
"path": args.db,
|
|
234
|
+
"profile": schema.profile,
|
|
235
|
+
"max_rowid": top,
|
|
236
|
+
"optional_missing": sorted(schema.optional_missing),
|
|
237
|
+
"message": "Full Disk Access OK",
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
return EXIT_OK
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _tail_round(
|
|
244
|
+
db_path: str, since: int | None, limit: int | None, out: IO[str], err: IO[str]
|
|
245
|
+
) -> int:
|
|
246
|
+
"""One ``messages_after`` round on a fresh connection; returns the new cursor."""
|
|
247
|
+
conn = open_connection(db_path)
|
|
248
|
+
try:
|
|
249
|
+
schema = Schema(conn)
|
|
250
|
+
top = max_rowid(conn)
|
|
251
|
+
cursor = top if since is None else since
|
|
252
|
+
if cursor > top:
|
|
253
|
+
# The database was rebuilt (CursorAhead in watch terms); restart at now.
|
|
254
|
+
err.write(f"warning: rowid {cursor} is ahead of MAX(ROWID) {top}; restarting at now\n")
|
|
255
|
+
cursor = top
|
|
256
|
+
for m in messages_after(conn, schema, cursor, limit=limit):
|
|
257
|
+
_emit(out, m.to_dict())
|
|
258
|
+
cursor = m.rowid
|
|
259
|
+
return cursor
|
|
260
|
+
finally:
|
|
261
|
+
conn.close()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _cmd_tail(
|
|
265
|
+
args: argparse.Namespace, out: IO[str], err: IO[str], sleep: Callable[[float], None]
|
|
266
|
+
) -> int:
|
|
267
|
+
since: int | None = args.since_rowid
|
|
268
|
+
if not args.follow:
|
|
269
|
+
_tail_round(args.db, since, args.limit, out, err)
|
|
270
|
+
return EXIT_OK
|
|
271
|
+
cursor = since
|
|
272
|
+
try:
|
|
273
|
+
while True:
|
|
274
|
+
try:
|
|
275
|
+
cursor = _tail_round(args.db, cursor, args.limit, out, err)
|
|
276
|
+
except ChatDBBusy:
|
|
277
|
+
pass # retry next round, nothing advanced
|
|
278
|
+
sleep(args.interval)
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
return EXIT_INTERRUPTED
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cmd_chats(args: argparse.Namespace, out: IO[str]) -> int:
|
|
284
|
+
conn = open_connection(args.db)
|
|
285
|
+
try:
|
|
286
|
+
for c in chats_by_activity(conn, limit=args.limit):
|
|
287
|
+
_emit(out, c.to_dict())
|
|
288
|
+
finally:
|
|
289
|
+
conn.close()
|
|
290
|
+
return EXIT_OK
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _cmd_search(args: argparse.Namespace, out: IO[str]) -> int:
|
|
294
|
+
conn = open_connection(args.db)
|
|
295
|
+
try:
|
|
296
|
+
for hit in search_hits(conn, args.text, limit=args.limit):
|
|
297
|
+
_emit(out, hit.to_dict())
|
|
298
|
+
finally:
|
|
299
|
+
conn.close()
|
|
300
|
+
return EXIT_OK
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main(
|
|
304
|
+
argv: Sequence[str] | None = None,
|
|
305
|
+
*,
|
|
306
|
+
stdout: IO[str] | None = None,
|
|
307
|
+
stderr: IO[str] | None = None,
|
|
308
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
309
|
+
) -> int:
|
|
310
|
+
"""Run the CLI and return its exit code; it never raises ``SystemExit``.
|
|
311
|
+
|
|
312
|
+
``stdout``/``stderr`` default to ``sys.stdout``/``sys.stderr``; ``sleep`` is
|
|
313
|
+
the pause between ``--follow`` rounds (injectable for tests). A usage error
|
|
314
|
+
returns :data:`EXIT_USAGE` (64) with the usage line and explanation on
|
|
315
|
+
``stderr``; ``--help`` prints to ``sys.stdout`` and returns 0. A
|
|
316
|
+
``BrokenPipeError`` from ``stdout`` (its reader closed the pipe) ends the
|
|
317
|
+
command with :data:`EXIT_OK` after redirecting the broken descriptor to
|
|
318
|
+
``os.devnull``, so nothing is printed at shutdown.
|
|
319
|
+
"""
|
|
320
|
+
out = sys.stdout if stdout is None else stdout
|
|
321
|
+
err = sys.stderr if stderr is None else stderr
|
|
322
|
+
try:
|
|
323
|
+
args = build_parser().parse_args(argv)
|
|
324
|
+
except UsageError as exc:
|
|
325
|
+
err.write(exc.usage)
|
|
326
|
+
err.write(f"{exc.message}\n")
|
|
327
|
+
return EXIT_USAGE
|
|
328
|
+
except SystemExit as exc: # --help; argparse exits 0 after printing
|
|
329
|
+
return EXIT_OK if exc.code in (None, 0) else EXIT_USAGE
|
|
330
|
+
try:
|
|
331
|
+
if args.command == "check":
|
|
332
|
+
return _cmd_check(args, out)
|
|
333
|
+
if args.command == "tail":
|
|
334
|
+
return _cmd_tail(args, out, err, sleep)
|
|
335
|
+
if args.command == "chats":
|
|
336
|
+
return _cmd_chats(args, out)
|
|
337
|
+
if args.command == "search":
|
|
338
|
+
return _cmd_search(args, out)
|
|
339
|
+
raise AssertionError(f"unhandled command {args.command!r}") # pragma: no cover
|
|
340
|
+
except ChatDBAccessError as exc:
|
|
341
|
+
err.write(f"error: {exc}\n")
|
|
342
|
+
return EXIT_ACCESS
|
|
343
|
+
except ChatDBError as exc:
|
|
344
|
+
err.write(f"error: {exc}\n")
|
|
345
|
+
return EXIT_ERROR
|
|
346
|
+
except sqlite3.Error as exc:
|
|
347
|
+
err.write(f"error: sqlite3: {exc}\n")
|
|
348
|
+
return EXIT_ERROR
|
|
349
|
+
except BrokenPipeError:
|
|
350
|
+
_discard_further_output(out)
|
|
351
|
+
return EXIT_OK
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__": # pragma: no cover
|
|
355
|
+
sys.exit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Attachment queries (DESIGN.md sections 4.4, 6.1, 8.4 attachments).
|
|
2
|
+
|
|
3
|
+
Every function takes an open read-only ``sqlite3.Connection`` whose
|
|
4
|
+
``row_factory`` is ``sqlite3.Row`` (what :func:`imessage_chatdb.open_connection`
|
|
5
|
+
returns) and ends in ``fetchall()``.
|
|
6
|
+
|
|
7
|
+
The default exclusion is the ``transfer_name`` suffix
|
|
8
|
+
``.pluginPayloadAttachment`` (rich-link payload blobs that Messages renders as
|
|
9
|
+
cards), **never** ``hide_attachment``: on a live database ``hide_attachment=1``
|
|
10
|
+
also covers a handful of ordinary images that the relay surfaces today
|
|
11
|
+
(section 2). ``include_plugin_payloads=True`` turns the filter off.
|
|
12
|
+
|
|
13
|
+
The two JSON-visible statements, ``ATTACHMENTS_FOR`` (no ``ORDER BY``: SQLite
|
|
14
|
+
scan order) and ``CHAT_ATTACHMENTS`` (``ORDER BY m.date DESC``, ties in scan
|
|
15
|
+
order), are executed as the verbatim ``sql.py`` constants with **only** extra
|
|
16
|
+
result columns appended to the relay's select list (:func:`_widen`): the
|
|
17
|
+
relay's columns come first under the relay's aliases, then ``a.ROWID AS
|
|
18
|
+
att_rowid, a.*`` so the ``Attachment`` model can carry ``rowid``, ``filename``
|
|
19
|
+
and the optional columns. Every clause from ``FROM`` onward is byte-equal to
|
|
20
|
+
the relay's, and the extra columns are all read from ``a`` by rowid, so the
|
|
21
|
+
query plan (and therefore the implicit row order) is the relay's;
|
|
22
|
+
``tests/test_attachments.py`` asserts both the text derivation and
|
|
23
|
+
``EXPLAIN QUERY PLAN`` equality on every schema profile. Optional
|
|
24
|
+
``attachment`` columns (``uti``, ``total_bytes``, ``is_sticker``,
|
|
25
|
+
``hide_attachment``) are read by name when present and are ``None`` otherwise,
|
|
26
|
+
so no schema introspection query is needed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
import sqlite3
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from .models import PLUGIN_PAYLOAD_SUFFIX, Attachment
|
|
37
|
+
from .sql import ATTACHMENTS_FOR, CHAT_ATTACHMENTS, PAYLOAD_FOR, expand_in
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"is_plugin_payload",
|
|
41
|
+
"attachments_for",
|
|
42
|
+
"attachment_by_guid",
|
|
43
|
+
"chat_attachments",
|
|
44
|
+
"payload_for",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: The columns appended to the relay's select list so ``row_to_attachment``
|
|
48
|
+
#: can build the model (``att_rowid`` explicit; ``a.*`` for everything else).
|
|
49
|
+
WIDE_EXTRA_COLUMNS = "a.ROWID AS att_rowid, a.*"
|
|
50
|
+
|
|
51
|
+
_FROM_CLAUSE = re.compile(r"\n\s+FROM ")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _widen(statement: str, extra: str = WIDE_EXTRA_COLUMNS) -> str:
|
|
55
|
+
"""Append ``extra`` to the select list of a verbatim relay statement.
|
|
56
|
+
|
|
57
|
+
The statement text from its first ``FROM`` clause onward is returned
|
|
58
|
+
byte-for-byte; only ``, extra`` is inserted at the end of the select list.
|
|
59
|
+
"""
|
|
60
|
+
m = _FROM_CLAUSE.search(statement)
|
|
61
|
+
if m is None:
|
|
62
|
+
raise ValueError("statement has no FROM clause on its own line")
|
|
63
|
+
return statement[: m.start()] + ", " + extra + statement[m.start() :]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The verbatim ATTACHMENTS_FOR + the extra columns.
|
|
67
|
+
_ATTACHMENTS_FOR_WIDE = _widen(ATTACHMENTS_FOR)
|
|
68
|
+
|
|
69
|
+
# The verbatim CHAT_ATTACHMENTS + the owning message's ROWID
|
|
70
|
+
# and the extra columns; the ``ORDER BY m.date DESC`` is the relay's.
|
|
71
|
+
_CHAT_ATTACHMENTS_WIDE = _widen(CHAT_ATTACHMENTS, "m.ROWID AS mid, " + WIDE_EXTRA_COLUMNS)
|
|
72
|
+
|
|
73
|
+
# One attachment by guid, with the owning message when it has one (the verbatim
|
|
74
|
+
# ATTACHMENT_BY_GUID reads only filename/mime/transfer_name).
|
|
75
|
+
_ATTACHMENT_BY_GUID_WIDE = """SELECT maj.message_id AS mid, a.ROWID AS att_rowid, a.*
|
|
76
|
+
FROM attachment a
|
|
77
|
+
LEFT JOIN message_attachment_join maj ON maj.attachment_id = a.ROWID
|
|
78
|
+
WHERE a.guid = ?
|
|
79
|
+
ORDER BY maj.message_id LIMIT 1"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_plugin_payload(transfer_name: str | None) -> bool:
|
|
83
|
+
"""The relay's filter: ``(transfer_name or "").endswith(".pluginPayloadAttachment")``."""
|
|
84
|
+
return (transfer_name or "").endswith(PLUGIN_PAYLOAD_SUFFIX)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _opt(r: sqlite3.Row, keys: set[str], name: str) -> Any:
|
|
88
|
+
"""``r[name]`` when the row carries ``name``, else ``None`` (missing optional column)."""
|
|
89
|
+
return r[name] if name in keys else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _opt_bool(r: sqlite3.Row, keys: set[str], name: str) -> bool | None:
|
|
93
|
+
v = _opt(r, keys, name)
|
|
94
|
+
return None if v is None else bool(v)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _opt_int(r: sqlite3.Row, keys: set[str], name: str) -> int | None:
|
|
98
|
+
v = _opt(r, keys, name)
|
|
99
|
+
return None if v is None else int(v)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _opt_str(r: sqlite3.Row, keys: set[str], name: str) -> str | None:
|
|
103
|
+
v = _opt(r, keys, name)
|
|
104
|
+
return None if v is None else str(v)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def row_to_attachment(
|
|
108
|
+
r: sqlite3.Row, *, message_rowid: int, message_date: int | None
|
|
109
|
+
) -> Attachment:
|
|
110
|
+
"""Build an ``Attachment`` from a ``a.*`` row (plus ``att_rowid``).
|
|
111
|
+
|
|
112
|
+
``is_sticker`` / ``hide_attachment`` become ``bool`` (``None`` when the
|
|
113
|
+
column is absent or NULL); ``total_bytes`` is an ``int`` or ``None``.
|
|
114
|
+
"""
|
|
115
|
+
keys = set(r.keys())
|
|
116
|
+
return Attachment(
|
|
117
|
+
message_rowid=message_rowid,
|
|
118
|
+
rowid=int(r["att_rowid"]),
|
|
119
|
+
guid=str(r["guid"]),
|
|
120
|
+
mime_type=_opt_str(r, keys, "mime_type"),
|
|
121
|
+
transfer_name=_opt_str(r, keys, "transfer_name"),
|
|
122
|
+
filename=_opt_str(r, keys, "filename"),
|
|
123
|
+
uti=_opt_str(r, keys, "uti"),
|
|
124
|
+
total_bytes=_opt_int(r, keys, "total_bytes"),
|
|
125
|
+
is_sticker=_opt_bool(r, keys, "is_sticker"),
|
|
126
|
+
hide_attachment=_opt_bool(r, keys, "hide_attachment"),
|
|
127
|
+
message_date=message_date,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def attachments_for(
|
|
132
|
+
conn: sqlite3.Connection,
|
|
133
|
+
rowids: Iterable[int],
|
|
134
|
+
*,
|
|
135
|
+
include_plugin_payloads: bool = False,
|
|
136
|
+
) -> dict[int, list[Attachment]]:
|
|
137
|
+
"""Attachments of the given message ROWIDs, keyed by message ROWID.
|
|
138
|
+
|
|
139
|
+
One ``IN`` query (no ``ORDER BY``, as the relay); no query at all when
|
|
140
|
+
``rowids`` is empty. Messages without attachments have no key. Plugin
|
|
141
|
+
payload rows are skipped unless ``include_plugin_payloads``.
|
|
142
|
+
"""
|
|
143
|
+
ids = list(dict.fromkeys(int(x) for x in rowids))
|
|
144
|
+
if not ids:
|
|
145
|
+
return {}
|
|
146
|
+
rows = conn.execute(expand_in(_ATTACHMENTS_FOR_WIDE, len(ids)), tuple(ids)).fetchall()
|
|
147
|
+
out: dict[int, list[Attachment]] = {}
|
|
148
|
+
for r in rows:
|
|
149
|
+
if not include_plugin_payloads and is_plugin_payload(r["transfer_name"]):
|
|
150
|
+
continue
|
|
151
|
+
mid = int(r["mid"])
|
|
152
|
+
out.setdefault(mid, []).append(row_to_attachment(r, message_rowid=mid, message_date=None))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def attachment_by_guid(conn: sqlite3.Connection, guid: str) -> Attachment | None:
|
|
157
|
+
"""The attachment with ``guid``, or ``None``.
|
|
158
|
+
|
|
159
|
+
``message_rowid`` is the lowest joined message ROWID, or ``0`` when the
|
|
160
|
+
attachment has no ``message_attachment_join`` row. No plugin-payload
|
|
161
|
+
filter: a caller asking by guid wants that row.
|
|
162
|
+
"""
|
|
163
|
+
rows = conn.execute(_ATTACHMENT_BY_GUID_WIDE, (guid,)).fetchall()
|
|
164
|
+
if not rows:
|
|
165
|
+
return None
|
|
166
|
+
r = rows[0]
|
|
167
|
+
mid = 0 if r["mid"] is None else int(r["mid"])
|
|
168
|
+
return row_to_attachment(r, message_rowid=mid, message_date=None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def chat_attachments(
|
|
172
|
+
conn: sqlite3.Connection,
|
|
173
|
+
chat_guid: str,
|
|
174
|
+
*,
|
|
175
|
+
include_plugin_payloads: bool = False,
|
|
176
|
+
) -> list[Attachment]:
|
|
177
|
+
"""Every attachment in the chat with ``chat_guid``, newest message first.
|
|
178
|
+
|
|
179
|
+
The verbatim ``CHAT_ATTACHMENTS`` statement: ``ORDER BY m.date DESC``. Each
|
|
180
|
+
``Attachment.message_date`` carries the owning message's raw Apple date.
|
|
181
|
+
"""
|
|
182
|
+
rows = conn.execute(_CHAT_ATTACHMENTS_WIDE, (chat_guid,)).fetchall()
|
|
183
|
+
out: list[Attachment] = []
|
|
184
|
+
for r in rows:
|
|
185
|
+
if not include_plugin_payloads and is_plugin_payload(r["transfer_name"]):
|
|
186
|
+
continue
|
|
187
|
+
date = r["date"]
|
|
188
|
+
out.append(
|
|
189
|
+
row_to_attachment(
|
|
190
|
+
r,
|
|
191
|
+
message_rowid=int(r["mid"]),
|
|
192
|
+
message_date=None if date is None else int(date),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def payload_for(conn: sqlite3.Connection, rowid: int) -> bytes | None:
|
|
199
|
+
"""Raw ``message.payload_data`` for ``rowid`` (``None`` when absent or NULL).
|
|
200
|
+
|
|
201
|
+
Also ``None`` when the schema has no ``payload_data`` column at all
|
|
202
|
+
(optional per section 4.2).
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
rows = conn.execute(PAYLOAD_FOR, (rowid,)).fetchall()
|
|
206
|
+
except sqlite3.OperationalError as exc:
|
|
207
|
+
if "no such column" in str(exc).lower():
|
|
208
|
+
return None
|
|
209
|
+
raise
|
|
210
|
+
if not rows:
|
|
211
|
+
return None
|
|
212
|
+
data = rows[0][0]
|
|
213
|
+
if data is None:
|
|
214
|
+
return None
|
|
215
|
+
return bytes(data)
|