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.
@@ -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)