contextbase-plugin-imessage-local 0.2.9__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,384 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import Iterable, Iterator, Mapping
5
+ from datetime import date, datetime
6
+ from typing import Any
7
+
8
+ from ..attributed_body import decode_attributed_body
9
+ from .ctx import (
10
+ AttachmentRow,
11
+ ChatHandleJoinRow,
12
+ ChatMessageJoinRow,
13
+ ChatRow,
14
+ HandleRow,
15
+ MessageAttachmentJoinRow,
16
+ MessageRow,
17
+ )
18
+ from .ingress import (
19
+ IMessageAttachment,
20
+ IMessageChat,
21
+ IMessageChatHandleJoin,
22
+ IMessageChatMessageJoin,
23
+ IMessageHandle,
24
+ IMessageMessage,
25
+ IMessageMessageAttachmentJoin,
26
+ )
27
+
28
+
29
+ def _json_safe(value: Any) -> Any:
30
+ """Convert a decoded-attribute structure to JSON-serializable values.
31
+
32
+ ``bytes`` inside attribute dicts (e.g. DataDetected bplist blobs) become
33
+ base64 strings so the whole tree fits into a JSONB column without
34
+ silently dropping any bytes.
35
+ """
36
+ if value is None or isinstance(value, (str, int, float, bool)):
37
+ return value
38
+ if isinstance(value, bytes):
39
+ return base64.b64encode(value).decode("ascii")
40
+ if isinstance(value, (datetime, date)):
41
+ return value.isoformat()
42
+ if isinstance(value, Mapping):
43
+ return {str(k): _json_safe(v) for k, v in value.items()}
44
+ if isinstance(value, (list, tuple)):
45
+ return [_json_safe(v) for v in value]
46
+ return str(value)
47
+
48
+
49
+ def handles_to_ctx_models(
50
+ *,
51
+ binding_id: str,
52
+ rows: Iterable[IMessageHandle],
53
+ ) -> Iterator[HandleRow]:
54
+ for handle in rows:
55
+ yield HandleRow(
56
+ ctx_binding_id=binding_id,
57
+ ctx_source_updated_at=None,
58
+ id=handle.id,
59
+ service=handle.service,
60
+ country=handle.country,
61
+ uncanonicalized_id=handle.uncanonicalized_id,
62
+ person_centric_id=handle.person_centric_id,
63
+ )
64
+
65
+
66
+ def chats_to_ctx_models(
67
+ *,
68
+ binding_id: str,
69
+ rows: Iterable[IMessageChat],
70
+ ) -> Iterator[ChatRow]:
71
+ for chat in rows:
72
+ yield ChatRow(
73
+ ctx_binding_id=binding_id,
74
+ ctx_source_updated_at=None,
75
+ guid=chat.guid,
76
+ style=chat.style,
77
+ state=chat.state,
78
+ account_id=chat.account_id,
79
+ properties=chat.properties,
80
+ chat_identifier=chat.chat_identifier,
81
+ service_name=chat.service_name,
82
+ room_name=chat.room_name,
83
+ account_login=chat.account_login,
84
+ is_archived=chat.is_archived,
85
+ last_addressed_handle=chat.last_addressed_handle,
86
+ display_name=chat.display_name,
87
+ group_id=chat.group_id,
88
+ is_filtered=chat.is_filtered,
89
+ successful_query=chat.successful_query,
90
+ engram_id=chat.engram_id,
91
+ server_change_token=chat.server_change_token,
92
+ ck_sync_state=chat.ck_sync_state,
93
+ original_group_id=chat.original_group_id,
94
+ last_read_message_timestamp=chat.last_read_message_timestamp,
95
+ sr_server_change_token=chat.sr_server_change_token,
96
+ sr_ck_sync_state=chat.sr_ck_sync_state,
97
+ cloudkit_record_id=chat.cloudkit_record_id,
98
+ sr_cloudkit_record_id=chat.sr_cloudkit_record_id,
99
+ last_addressed_sim_id=chat.last_addressed_sim_id,
100
+ is_blackholed=chat.is_blackholed,
101
+ syndication_date=chat.syndication_date,
102
+ syndication_type=chat.syndication_type,
103
+ is_recovered=chat.is_recovered,
104
+ is_deleting_incoming_messages=chat.is_deleting_incoming_messages,
105
+ )
106
+
107
+
108
+ def attachments_to_ctx_models(
109
+ *,
110
+ binding_id: str,
111
+ rows: Iterable[IMessageAttachment],
112
+ ) -> Iterator[AttachmentRow]:
113
+ for attachment in rows:
114
+ yield AttachmentRow(
115
+ ctx_binding_id=binding_id,
116
+ ctx_source_updated_at=attachment.created_date,
117
+ guid=attachment.guid,
118
+ created_date=attachment.created_date,
119
+ start_date=attachment.start_date,
120
+ filename=attachment.filename,
121
+ uti=attachment.uti,
122
+ mime_type=attachment.mime_type,
123
+ transfer_state=attachment.transfer_state,
124
+ is_outgoing=attachment.is_outgoing,
125
+ user_info=attachment.user_info,
126
+ transfer_name=attachment.transfer_name,
127
+ total_bytes=attachment.total_bytes,
128
+ is_sticker=attachment.is_sticker,
129
+ sticker_user_info=attachment.sticker_user_info,
130
+ attribution_info=attachment.attribution_info,
131
+ hide_attachment=attachment.hide_attachment,
132
+ ck_sync_state=attachment.ck_sync_state,
133
+ ck_server_change_token_blob=attachment.ck_server_change_token_blob,
134
+ ck_record_id=attachment.ck_record_id,
135
+ original_guid=attachment.original_guid,
136
+ sr_ck_sync_state=attachment.sr_ck_sync_state,
137
+ sr_ck_server_change_token_blob=attachment.sr_ck_server_change_token_blob,
138
+ sr_ck_record_id=attachment.sr_ck_record_id,
139
+ is_commsafety_sensitive=attachment.is_commsafety_sensitive,
140
+ emoji_image_content_identifier=attachment.emoji_image_content_identifier,
141
+ emoji_image_short_description=attachment.emoji_image_short_description,
142
+ preview_generation_state=attachment.preview_generation_state,
143
+ )
144
+
145
+
146
+ def messages_to_ctx_models(
147
+ *,
148
+ binding_id: str,
149
+ rows: Iterable[IMessageMessage],
150
+ handle_lookup: Mapping[int, tuple[str, str]],
151
+ ) -> Iterator[MessageRow]:
152
+ for msg in rows:
153
+ decoded = (
154
+ decode_attributed_body(msg.attributed_body) if msg.attributed_body else None
155
+ )
156
+ if decoded is None:
157
+ decoded_text: str | None = None
158
+ attribute_runs: list[dict[str, Any]] | None = None
159
+ else:
160
+ decoded_text = decoded.text
161
+ attribute_runs = [
162
+ {
163
+ "location": run.location,
164
+ "length": run.length,
165
+ "attributes": _json_safe(dict(run.attributes)),
166
+ }
167
+ for run in decoded.runs
168
+ ]
169
+
170
+ text = decoded_text if decoded_text is not None else msg.text
171
+
172
+ handle_natural_id, handle_natural_service = handle_lookup.get(
173
+ msg.handle_id, (None, None)
174
+ )
175
+ other_handle_natural_id, other_handle_natural_service = handle_lookup.get(
176
+ msg.other_handle, (None, None)
177
+ )
178
+
179
+ source_updated_at = max(
180
+ (
181
+ timestamp
182
+ for timestamp in (msg.date, msg.date_edited, msg.date_retracted)
183
+ if timestamp is not None
184
+ ),
185
+ default=None,
186
+ )
187
+
188
+ yield MessageRow(
189
+ ctx_binding_id=binding_id,
190
+ ctx_source_updated_at=source_updated_at,
191
+ guid=msg.guid,
192
+ text=text,
193
+ replace=msg.replace,
194
+ service_center=msg.service_center,
195
+ handle_id=msg.handle_id,
196
+ handle_natural_id=handle_natural_id,
197
+ handle_natural_service=handle_natural_service,
198
+ subject=msg.subject,
199
+ country=msg.country,
200
+ attributed_body=msg.attributed_body,
201
+ attribute_runs=attribute_runs,
202
+ version=msg.version,
203
+ type=msg.type,
204
+ service=msg.service,
205
+ account=msg.account,
206
+ account_guid=msg.account_guid,
207
+ error=msg.error,
208
+ date=msg.date,
209
+ date_read=msg.date_read,
210
+ date_delivered=msg.date_delivered,
211
+ is_delivered=msg.is_delivered,
212
+ is_finished=msg.is_finished,
213
+ is_emote=msg.is_emote,
214
+ is_from_me=msg.is_from_me,
215
+ is_empty=msg.is_empty,
216
+ is_delayed=msg.is_delayed,
217
+ is_auto_reply=msg.is_auto_reply,
218
+ is_prepared=msg.is_prepared,
219
+ is_read=msg.is_read,
220
+ is_system_message=msg.is_system_message,
221
+ is_sent=msg.is_sent,
222
+ has_dd_results=msg.has_dd_results,
223
+ is_service_message=msg.is_service_message,
224
+ is_forward=msg.is_forward,
225
+ was_downgraded=msg.was_downgraded,
226
+ is_archive=msg.is_archive,
227
+ was_data_detected=msg.was_data_detected,
228
+ was_deduplicated=msg.was_deduplicated,
229
+ is_audio_message=msg.is_audio_message,
230
+ is_played=msg.is_played,
231
+ date_played=msg.date_played,
232
+ item_type=msg.item_type,
233
+ other_handle=msg.other_handle,
234
+ other_handle_natural_id=other_handle_natural_id,
235
+ other_handle_natural_service=other_handle_natural_service,
236
+ group_title=msg.group_title,
237
+ group_action_type=msg.group_action_type,
238
+ share_status=msg.share_status,
239
+ share_direction=msg.share_direction,
240
+ is_expirable=msg.is_expirable,
241
+ expire_state=msg.expire_state,
242
+ message_action_type=msg.message_action_type,
243
+ message_source=msg.message_source,
244
+ associated_message_guid=msg.associated_message_guid,
245
+ associated_message_type=msg.associated_message_type,
246
+ balloon_bundle_id=msg.balloon_bundle_id,
247
+ payload_data=msg.payload_data,
248
+ expressive_send_style_id=msg.expressive_send_style_id,
249
+ associated_message_range_location=msg.associated_message_range_location,
250
+ associated_message_range_length=msg.associated_message_range_length,
251
+ time_expressive_send_played=msg.time_expressive_send_played,
252
+ message_summary_info=msg.message_summary_info,
253
+ destination_caller_id=msg.destination_caller_id,
254
+ is_corrupt=msg.is_corrupt,
255
+ reply_to_guid=msg.reply_to_guid,
256
+ sort_id=msg.sort_id,
257
+ is_spam=msg.is_spam,
258
+ has_unseen_mention=msg.has_unseen_mention,
259
+ thread_originator_guid=msg.thread_originator_guid,
260
+ thread_originator_part=msg.thread_originator_part,
261
+ syndication_ranges=msg.syndication_ranges,
262
+ was_delivered_quietly=msg.was_delivered_quietly,
263
+ did_notify_recipient=msg.did_notify_recipient,
264
+ synced_syndication_ranges=msg.synced_syndication_ranges,
265
+ date_retracted=msg.date_retracted,
266
+ date_edited=msg.date_edited,
267
+ was_detonated=msg.was_detonated,
268
+ part_count=msg.part_count,
269
+ is_stewie=msg.is_stewie,
270
+ is_kt_verified=msg.is_kt_verified,
271
+ is_sos=msg.is_sos,
272
+ is_critical=msg.is_critical,
273
+ bia_reference_id=msg.bia_reference_id,
274
+ fallback_hash=msg.fallback_hash,
275
+ associated_message_emoji=msg.associated_message_emoji,
276
+ is_pending_satellite_send=msg.is_pending_satellite_send,
277
+ needs_relay=msg.needs_relay,
278
+ schedule_type=msg.schedule_type,
279
+ schedule_state=msg.schedule_state,
280
+ sent_or_received_off_grid=msg.sent_or_received_off_grid,
281
+ date_recovered=msg.date_recovered,
282
+ )
283
+
284
+
285
+ def chat_handle_joins_to_ctx_models(
286
+ *,
287
+ binding_id: str,
288
+ rows: Iterable[IMessageChatHandleJoin],
289
+ chat_lookup: Mapping[int, str],
290
+ handle_lookup: Mapping[int, tuple[str, str]],
291
+ ) -> Iterator[ChatHandleJoinRow]:
292
+ """Resolve chat rowid -> guid and handle rowid -> (id, service).
293
+
294
+ Raises if a join row references a missing chat or handle — the source
295
+ enforces FK integrity, so an unresolvable row indicates either
296
+ corruption or a pre-load race. Fail loudly per the "errors must be
297
+ loud" rule.
298
+ """
299
+ for row in rows:
300
+ chat_guid = chat_lookup.get(row.chat_id)
301
+ if chat_guid is None:
302
+ raise RuntimeError(
303
+ f"chat_handle_join row references missing chat_id={row.chat_id}"
304
+ )
305
+ handle_pair = handle_lookup.get(row.handle_id)
306
+ if handle_pair is None:
307
+ raise RuntimeError(
308
+ f"chat_handle_join row references missing handle_id={row.handle_id}"
309
+ )
310
+ handle_natural_id, handle_natural_service = handle_pair
311
+ yield ChatHandleJoinRow(
312
+ ctx_binding_id=binding_id,
313
+ ctx_source_updated_at=None,
314
+ chat_id=row.chat_id,
315
+ chat_guid=chat_guid,
316
+ handle_id=row.handle_id,
317
+ handle_natural_id=handle_natural_id,
318
+ handle_natural_service=handle_natural_service,
319
+ )
320
+
321
+
322
+ def chat_message_joins_to_ctx_models(
323
+ *,
324
+ binding_id: str,
325
+ rows: Iterable[IMessageChatMessageJoin],
326
+ chat_lookup: Mapping[int, str],
327
+ message_lookup: Mapping[int, str],
328
+ ) -> Iterator[ChatMessageJoinRow]:
329
+ """Resolve chat rowid -> guid and message rowid -> guid.
330
+
331
+ Fails loudly on unresolvable rowids — the source declares FKs and the
332
+ triggers CASCADE on delete, so missing refs indicate corruption.
333
+ """
334
+ for row in rows:
335
+ chat_guid = chat_lookup.get(row.chat_id)
336
+ if chat_guid is None:
337
+ raise RuntimeError(
338
+ f"chat_message_join row references missing chat_id={row.chat_id}"
339
+ )
340
+ message_guid = message_lookup.get(row.message_id)
341
+ if message_guid is None:
342
+ raise RuntimeError(
343
+ f"chat_message_join row references missing message_id={row.message_id}"
344
+ )
345
+ yield ChatMessageJoinRow(
346
+ ctx_binding_id=binding_id,
347
+ ctx_source_updated_at=row.message_date,
348
+ chat_id=row.chat_id,
349
+ chat_guid=chat_guid,
350
+ message_id=row.message_id,
351
+ message_guid=message_guid,
352
+ message_date=row.message_date,
353
+ )
354
+
355
+
356
+ def message_attachment_joins_to_ctx_models(
357
+ *,
358
+ binding_id: str,
359
+ rows: Iterable[IMessageMessageAttachmentJoin],
360
+ message_lookup: Mapping[int, str],
361
+ attachment_lookup: Mapping[int, str],
362
+ ) -> Iterator[MessageAttachmentJoinRow]:
363
+ """Resolve message rowid -> guid and attachment rowid -> guid."""
364
+ for row in rows:
365
+ message_guid = message_lookup.get(row.message_id)
366
+ if message_guid is None:
367
+ raise RuntimeError(
368
+ f"message_attachment_join row references missing "
369
+ f"message_id={row.message_id}"
370
+ )
371
+ attachment_guid = attachment_lookup.get(row.attachment_id)
372
+ if attachment_guid is None:
373
+ raise RuntimeError(
374
+ f"message_attachment_join row references missing "
375
+ f"attachment_id={row.attachment_id}"
376
+ )
377
+ yield MessageAttachmentJoinRow(
378
+ ctx_binding_id=binding_id,
379
+ ctx_source_updated_at=None,
380
+ message_id=row.message_id,
381
+ message_guid=message_guid,
382
+ attachment_id=row.attachment_id,
383
+ attachment_guid=attachment_guid,
384
+ )
@@ -0,0 +1,7 @@
1
+ {
2
+ "auth": {
3
+ "type": "none"
4
+ },
5
+ "mode": "dagster",
6
+ "plugin_id": "imessage_local"
7
+ }
File without changes
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import dlt
10
+ from shared_plugins.naming import (
11
+ dlt_resource_name,
12
+ dlt_source_name,
13
+ plugin_id_from_module,
14
+ )
15
+ from shared_plugins.resources import ctx_dlt_resource
16
+ from shared_plugins.sqlite import sqlite_snapshot
17
+ from sqlalchemy import Engine, create_engine, select
18
+ from sqlalchemy.orm import Session
19
+
20
+ from ..binding_config import IMessageLocalBindingConfig
21
+ from ..models.ctx import (
22
+ AttachmentRow,
23
+ ChatHandleJoinRow,
24
+ ChatMessageJoinRow,
25
+ ChatRow,
26
+ HandleRow,
27
+ MessageAttachmentJoinRow,
28
+ MessageRow,
29
+ )
30
+ from ..models.ingress import (
31
+ IMessageAttachment,
32
+ IMessageChat,
33
+ IMessageChatHandleJoin,
34
+ IMessageChatMessageJoin,
35
+ IMessageHandle,
36
+ IMessageMessage,
37
+ IMessageMessageAttachmentJoin,
38
+ )
39
+ from ..models.translators import (
40
+ attachments_to_ctx_models,
41
+ chat_handle_joins_to_ctx_models,
42
+ chat_message_joins_to_ctx_models,
43
+ chats_to_ctx_models,
44
+ handles_to_ctx_models,
45
+ message_attachment_joins_to_ctx_models,
46
+ messages_to_ctx_models,
47
+ )
48
+
49
+ PLUGIN_ID = plugin_id_from_module(__file__)
50
+ JOB = "snapshot"
51
+ LOGGER = logging.getLogger(__name__)
52
+ MERGE_WRITE_DISPOSITION = {"disposition": "merge", "strategy": "delete-insert"}
53
+ MERGE_KEY = ("_ctx_binding_id",)
54
+
55
+
56
+ @contextmanager
57
+ def _db_snapshot(db_path: Path) -> Iterator[Engine]:
58
+ if not db_path.is_file():
59
+ raise RuntimeError(f"Missing iMessage database: '{db_path}'.")
60
+ with sqlite_snapshot(db_path) as snapshot_path:
61
+ engine = create_engine(f"sqlite:///{snapshot_path}")
62
+ try:
63
+ yield engine
64
+ finally:
65
+ engine.dispose()
66
+
67
+
68
+ @dlt.source(name=dlt_source_name(PLUGIN_ID, JOB))
69
+ def imessage_local_snapshot_source(
70
+ binding_id: str,
71
+ cfg: IMessageLocalBindingConfig,
72
+ ) -> tuple[Any, ...]:
73
+ chat_db = cfg.chat_db
74
+
75
+ @ctx_dlt_resource(
76
+ name=dlt_resource_name("handle"),
77
+ write_disposition=MERGE_WRITE_DISPOSITION,
78
+ merge_key=MERGE_KEY,
79
+ primary_key=("_ctx_binding_id", "id", "service"),
80
+ )
81
+ def handle_resource() -> Iterator[HandleRow]:
82
+ with _db_snapshot(chat_db) as engine:
83
+ with Session(engine) as session:
84
+ yield from handles_to_ctx_models(
85
+ binding_id=binding_id,
86
+ rows=session.scalars(select(IMessageHandle)),
87
+ )
88
+
89
+ @ctx_dlt_resource(
90
+ name=dlt_resource_name("chat"),
91
+ write_disposition=MERGE_WRITE_DISPOSITION,
92
+ merge_key=MERGE_KEY,
93
+ primary_key=("_ctx_binding_id", "guid"),
94
+ )
95
+ def chat_resource() -> Iterator[ChatRow]:
96
+ with _db_snapshot(chat_db) as engine:
97
+ with Session(engine) as session:
98
+ yield from chats_to_ctx_models(
99
+ binding_id=binding_id,
100
+ rows=session.scalars(select(IMessageChat)),
101
+ )
102
+
103
+ @ctx_dlt_resource(
104
+ name=dlt_resource_name("attachment"),
105
+ write_disposition=MERGE_WRITE_DISPOSITION,
106
+ merge_key=MERGE_KEY,
107
+ primary_key=("_ctx_binding_id", "guid"),
108
+ )
109
+ def attachment_resource() -> Iterator[AttachmentRow]:
110
+ with _db_snapshot(chat_db) as engine:
111
+ with Session(engine) as session:
112
+ yield from attachments_to_ctx_models(
113
+ binding_id=binding_id,
114
+ rows=session.scalars(select(IMessageAttachment)),
115
+ )
116
+
117
+ @ctx_dlt_resource(
118
+ name=dlt_resource_name("message"),
119
+ write_disposition=MERGE_WRITE_DISPOSITION,
120
+ merge_key=MERGE_KEY,
121
+ primary_key=("_ctx_binding_id", "guid"),
122
+ )
123
+ def message_resource() -> Iterator[MessageRow]:
124
+ with _db_snapshot(chat_db) as engine:
125
+ with Session(engine) as session:
126
+ handle_lookup = {
127
+ rowid: (id_, service)
128
+ for rowid, id_, service in session.execute(
129
+ select(
130
+ IMessageHandle.rowid,
131
+ IMessageHandle.id,
132
+ IMessageHandle.service,
133
+ )
134
+ )
135
+ }
136
+ yield from messages_to_ctx_models(
137
+ binding_id=binding_id,
138
+ rows=session.scalars(select(IMessageMessage)),
139
+ handle_lookup=handle_lookup,
140
+ )
141
+
142
+ @ctx_dlt_resource(
143
+ name=dlt_resource_name("chat_handle_join"),
144
+ write_disposition=MERGE_WRITE_DISPOSITION,
145
+ merge_key=MERGE_KEY,
146
+ primary_key=(
147
+ "_ctx_binding_id",
148
+ "chat_guid",
149
+ "handle_natural_id",
150
+ "handle_natural_service",
151
+ ),
152
+ )
153
+ def chat_handle_join_resource() -> Iterator[ChatHandleJoinRow]:
154
+ with _db_snapshot(chat_db) as engine:
155
+ with Session(engine) as session:
156
+ chat_lookup = dict(
157
+ session.execute(select(IMessageChat.rowid, IMessageChat.guid)).all()
158
+ )
159
+ handle_lookup = {
160
+ rowid: (id_, service)
161
+ for rowid, id_, service in session.execute(
162
+ select(
163
+ IMessageHandle.rowid,
164
+ IMessageHandle.id,
165
+ IMessageHandle.service,
166
+ )
167
+ )
168
+ }
169
+ yield from chat_handle_joins_to_ctx_models(
170
+ binding_id=binding_id,
171
+ rows=session.scalars(select(IMessageChatHandleJoin)),
172
+ chat_lookup=chat_lookup,
173
+ handle_lookup=handle_lookup,
174
+ )
175
+
176
+ @ctx_dlt_resource(
177
+ name=dlt_resource_name("chat_message_join"),
178
+ write_disposition=MERGE_WRITE_DISPOSITION,
179
+ merge_key=MERGE_KEY,
180
+ primary_key=("_ctx_binding_id", "chat_guid", "message_guid"),
181
+ )
182
+ def chat_message_join_resource() -> Iterator[ChatMessageJoinRow]:
183
+ with _db_snapshot(chat_db) as engine:
184
+ with Session(engine) as session:
185
+ chat_lookup = dict(
186
+ session.execute(select(IMessageChat.rowid, IMessageChat.guid)).all()
187
+ )
188
+ message_lookup = dict(
189
+ session.execute(
190
+ select(IMessageMessage.rowid, IMessageMessage.guid)
191
+ ).all()
192
+ )
193
+ yield from chat_message_joins_to_ctx_models(
194
+ binding_id=binding_id,
195
+ rows=session.scalars(select(IMessageChatMessageJoin)),
196
+ chat_lookup=chat_lookup,
197
+ message_lookup=message_lookup,
198
+ )
199
+
200
+ @ctx_dlt_resource(
201
+ name=dlt_resource_name("message_attachment_join"),
202
+ write_disposition=MERGE_WRITE_DISPOSITION,
203
+ merge_key=MERGE_KEY,
204
+ primary_key=("_ctx_binding_id", "message_guid", "attachment_guid"),
205
+ )
206
+ def message_attachment_join_resource() -> Iterator[MessageAttachmentJoinRow]:
207
+ with _db_snapshot(chat_db) as engine:
208
+ with Session(engine) as session:
209
+ message_lookup = dict(
210
+ session.execute(
211
+ select(IMessageMessage.rowid, IMessageMessage.guid)
212
+ ).all()
213
+ )
214
+ attachment_lookup = dict(
215
+ session.execute(
216
+ select(IMessageAttachment.rowid, IMessageAttachment.guid)
217
+ ).all()
218
+ )
219
+ yield from message_attachment_joins_to_ctx_models(
220
+ binding_id=binding_id,
221
+ rows=session.scalars(select(IMessageMessageAttachmentJoin)),
222
+ message_lookup=message_lookup,
223
+ attachment_lookup=attachment_lookup,
224
+ )
225
+
226
+ return (
227
+ handle_resource,
228
+ chat_resource,
229
+ attachment_resource,
230
+ message_resource,
231
+ chat_handle_join_resource,
232
+ chat_message_join_resource,
233
+ message_attachment_join_resource,
234
+ )