chatwire 0.2.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.
_version.py ADDED
@@ -0,0 +1,12 @@
1
+ """Single source of truth for the bridge release version.
2
+
3
+ Bumped at release time. Used by:
4
+ - web/main.py for /healthz and the update-check banner
5
+ - chatwire_cli.py for `--version`
6
+ - pyproject.toml dynamic version
7
+
8
+ Format: PEP 440-flavored semver. Pre-1.0 dev builds carry a `-dev` suffix
9
+ which the update-check JS treats as "skip the check" (no point pinging
10
+ GitHub when you cloned from main).
11
+ """
12
+ __version__ = "0.2.0"
bridge.py ADDED
@@ -0,0 +1,487 @@
1
+ """chatwire runtime core.
2
+
3
+ Responsibility breakdown:
4
+
5
+ - **This file** owns the chat.db poll loop, the relay-scope filter (SELF +
6
+ whitelist), bridge-echo dedup so our own outbound doesn't bounce back,
7
+ the JSONL debug mirror, and the integration registry.
8
+ - **`integrations/<name>/`** owns rendering inbound events on a specific
9
+ surface (Telegram, webhook, …) and translating that surface's user
10
+ actions into outbound iMessage sends. Integrations call back into this
11
+ file via the `BridgeContext` they receive in `start()`.
12
+
13
+ Outbound is initiated by an integration via `ctx.send_text` / `ctx.send_file`;
14
+ the context wraps the AppleScript send and records the echo so the next poll
15
+ doesn't relay our own send back through every integration.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import os
23
+ import time
24
+ from collections import deque
25
+ from pathlib import Path
26
+
27
+ import importlib
28
+ import importlib.metadata
29
+ import inspect
30
+
31
+ import config as _bridge_config # noqa: E402 — must run before env-consuming imports
32
+ CFG = _bridge_config.apply_to_environ()
33
+
34
+ from chat_db import ChatDBReader, InboundMessage
35
+ from contacts import load_lookup as load_contacts
36
+ from echo_log import register as echo_register, seen_recently as echo_seen
37
+ from chat_send import (
38
+ SendResult, send_file_confirm, send_file_to_chat_confirm,
39
+ send_text_confirm, send_text_to_chat_confirm,
40
+ )
41
+ from whitelist import all_groups as wl_all_groups, all_handles as wl_all
42
+ from integrations.base import BridgeContext, SendOutcome, SendTarget
43
+
44
+ logging.basicConfig(
45
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
46
+ level=logging.INFO,
47
+ )
48
+ # httpx at INFO prints the bot-token-bearing getUpdates URL every ~10s.
49
+ # Stderr is world-readable on this Mac; anything that leaks the token there
50
+ # owns the bot.
51
+ logging.getLogger("httpx").setLevel(logging.WARNING)
52
+ log = logging.getLogger("chatwire")
53
+
54
+ # ---------- core configuration ----------
55
+
56
+ SELF_HANDLES = {
57
+ h.strip().lower()
58
+ for h in os.environ.get("SELF_HANDLES", "").split(",")
59
+ if h.strip()
60
+ }
61
+
62
+ POLL_INTERVAL_S = float(os.environ.get("POLL_INTERVAL_S", "2"))
63
+
64
+ # Optional observer/debug log: when set, every relayed inbound message and
65
+ # successful outbound send is appended as one JSONL line. Not touched by
66
+ # normal operation; safe to `tail -f` from a separate SSH session.
67
+ DEBUG_MIRROR_FILE = os.environ.get("DEBUG_MIRROR_FILE", "").strip() or None
68
+
69
+ STATE_DIR = Path.home() / ".imessage-tg"
70
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
71
+ STATE_PATH = STATE_DIR / "state.json"
72
+
73
+ # ---------- relay scope ----------
74
+
75
+ def relay_handles() -> set[str]:
76
+ """Live view: SELF (env) + whitelist (runtime-mutable file)."""
77
+ return SELF_HANDLES | wl_all()
78
+
79
+
80
+ def relay_groups() -> set[str]:
81
+ """Live view of whitelisted group-chat GUIDs. Groups are opt-in: none are
82
+ seeded from env, they're added via inline search or /whitelist_add."""
83
+ return wl_all_groups()
84
+
85
+
86
+ # ---------- mirror (debug JSONL) ----------
87
+
88
+ def mirror(event: str, **fields: object) -> None:
89
+ """Append one JSONL line to DEBUG_MIRROR_FILE if set. Never raises."""
90
+ if not DEBUG_MIRROR_FILE:
91
+ return
92
+ line = json.dumps(
93
+ {"t": time.strftime("%Y-%m-%dT%H:%M:%S"), "event": event, **fields},
94
+ ensure_ascii=False, default=str,
95
+ )
96
+ try:
97
+ with open(DEBUG_MIRROR_FILE, "a", encoding="utf-8") as f:
98
+ f.write(line + "\n")
99
+ except Exception:
100
+ log.exception("mirror append failed")
101
+
102
+
103
+ # ---------- bridge-echo dedup ----------
104
+ # Short-lived memory of what the bridge itself just sent, so we don't echo
105
+ # our own outbound through the inbound poll. Keyed by (handle, body, ts).
106
+ # iMessage records bridge sends as is_from_me=1 with handle == recipient, so
107
+ # without this every outbound send would bounce back to every integration.
108
+ SENT_ECHO_WINDOW_S = 30
109
+ _sent_recently: deque[tuple[str, str, float]] = deque(maxlen=64)
110
+ # Photos can't be deduped by body (chat.db records them with text=""), so
111
+ # track a separate "we just sent SOME photo to <handle>" memory.
112
+ _sent_photos_recently: deque[tuple[str, float]] = deque(maxlen=64)
113
+
114
+
115
+ def _record_text_send(handle: str, body: str) -> None:
116
+ _sent_recently.append((handle.lower(), body.strip(), time.time()))
117
+ echo_register(handle, "text", body)
118
+
119
+
120
+ def _record_photo_send(handle: str) -> None:
121
+ _sent_photos_recently.append((handle.lower(), time.time()))
122
+ echo_register(handle, "photo")
123
+
124
+
125
+ def _is_bridge_text_echo(handle: str, body: str) -> bool:
126
+ now = time.time()
127
+ target = (handle.lower(), body.strip())
128
+ while _sent_recently and now - _sent_recently[0][2] > SENT_ECHO_WINDOW_S:
129
+ _sent_recently.popleft()
130
+ if any((h, b) == target for h, b, _ in _sent_recently):
131
+ return True
132
+ # Cross-process: web-originated text sends record into echo_log too.
133
+ return echo_seen(handle, "text", body, SENT_ECHO_WINDOW_S)
134
+
135
+
136
+ def _is_bridge_photo_echo(handle: str) -> bool:
137
+ now = time.time()
138
+ target = handle.lower()
139
+ while _sent_photos_recently and now - _sent_photos_recently[0][1] > SENT_ECHO_WINDOW_S:
140
+ _sent_photos_recently.popleft()
141
+ if any(h == target for h, _ in _sent_photos_recently):
142
+ return True
143
+ return echo_seen(handle, "photo", None, SENT_ECHO_WINDOW_S)
144
+
145
+
146
+ # ---------- inbound filter ----------
147
+
148
+ def _should_relay(msg: InboundMessage) -> bool:
149
+ # Group-chat path: gate on the chat GUID, not the sender handle. Members
150
+ # of a whitelisted group get relayed regardless of whether their own
151
+ # handle is on the 1:1 whitelist — that's the whole point.
152
+ if msg.is_group:
153
+ if msg.chat_guid not in relay_groups():
154
+ return False
155
+ # Our own outgoing to group chats arrive with handle='' (handle_id is
156
+ # NULL on outgoing group rows); don't relay those as incoming. Bridge
157
+ # echoes on group sends are handled by the same handle=='' filter.
158
+ if msg.is_from_me:
159
+ return False
160
+ return True
161
+
162
+ h = msg.handle.lower()
163
+ if h not in relay_handles():
164
+ return False
165
+ # Self-messages to your own Apple ID are recorded ONLY as is_from_me=1
166
+ # in chat.db (there's no separate "received" row on the same account).
167
+ # Relay those so the Phase A self-test actually produces traffic.
168
+ if msg.is_from_me:
169
+ if h not in SELF_HANDLES:
170
+ return False # bridge's own outbound to non-self contacts — skip
171
+ if _is_bridge_text_echo(msg.handle, msg.text):
172
+ return False # we just sent this text from an integration; don't bounce
173
+ if msg.attachments and _is_bridge_photo_echo(msg.handle):
174
+ return False # we just sent a photo from an integration; don't bounce
175
+ return True
176
+
177
+
178
+ def _group_consecutive(messages: list[InboundMessage]) -> list[list[InboundMessage]]:
179
+ """Collapse runs of relayable messages from the same handle AND chat into
180
+ batches.
181
+
182
+ Multi-line bursts ("yes\\nyes\\nlol") that arrive in one poll get rendered
183
+ as a single message instead of N separate ones. We also key on chat_guid
184
+ so an Eileen-in-Civi-kids message doesn't get merged with a Eileen-1:1
185
+ message that happens to arrive in the same poll. Messages with
186
+ attachments or threaded-reply context aren't merged because the
187
+ integration's prefix/quote rendering makes mashing them visually messy.
188
+ """
189
+ batches: list[list[InboundMessage]] = []
190
+ for m in messages:
191
+ if not _should_relay(m):
192
+ continue
193
+ if (batches
194
+ and batches[-1][-1].handle == m.handle
195
+ and batches[-1][-1].chat_guid == m.chat_guid
196
+ and not m.attachments
197
+ and not (m.parent_text or m.parent_handle)
198
+ and not batches[-1][-1].attachments
199
+ and not (batches[-1][-1].parent_text or batches[-1][-1].parent_handle)):
200
+ batches[-1].append(m)
201
+ else:
202
+ batches.append([m])
203
+ return batches
204
+
205
+
206
+ # ---------- BridgeContext implementation ----------
207
+
208
+ def _to_outcome(r: SendResult) -> SendOutcome:
209
+ """SendResult is the rich AppleScript-flavored shape; SendOutcome is the
210
+ integration-friendly shape. The interesting fields line up 1:1."""
211
+ return SendOutcome(
212
+ status=r.status,
213
+ hint=r.hint,
214
+ service=r.service or "",
215
+ fell_back_to_sms=r.fell_back_to_sms,
216
+ error=r.error,
217
+ original_error=r.original_error,
218
+ )
219
+
220
+
221
+ class BridgeContextImpl:
222
+ """Concrete BridgeContext passed to each integration's start().
223
+
224
+ Implements the `integrations.base.BridgeContext` Protocol (send_text,
225
+ send_file, name_for, mirror) plus a few in-repo extras consumed by the
226
+ bundled Telegram (and future web) integrations:
227
+
228
+ - `contacts`: shared handle_lc -> display_name dict (mutated by
229
+ reload_contacts; integrations read from it directly for bulk lookup).
230
+ - `chatdb`: the live ChatDBReader, for capability/group queries.
231
+ - `reload_contacts()`: re-read Contacts.app and update the shared dict.
232
+ - `relay_scope()`: SELF + whitelist + group GUIDs.
233
+
234
+ Third-party integrations should type their `ctx` as the Protocol and
235
+ only use the four declared methods. The extras are an in-repo
236
+ convenience.
237
+ """
238
+
239
+ def __init__(self, contacts: dict[str, str], chatdb: ChatDBReader | None):
240
+ self.contacts = contacts
241
+ self.chatdb = chatdb
242
+
243
+ async def send_text(self, target: SendTarget, body: str) -> SendOutcome:
244
+ if target.is_group:
245
+ r = await asyncio.to_thread(send_text_to_chat_confirm, target.value, body)
246
+ else:
247
+ r = await asyncio.to_thread(send_text_confirm, target.value, body)
248
+ # Group outgoing rows have handle='' and are filtered by
249
+ # _should_relay anyway; only 1:1 sends need echo registration.
250
+ _record_text_send(target.value, body)
251
+ return _to_outcome(r)
252
+
253
+ async def send_file(self, target: SendTarget, path: Path) -> SendOutcome:
254
+ if target.is_group:
255
+ r = await asyncio.to_thread(send_file_to_chat_confirm, target.value, path)
256
+ else:
257
+ r = await asyncio.to_thread(send_file_confirm, target.value, path)
258
+ _record_photo_send(target.value)
259
+ return _to_outcome(r)
260
+
261
+ def name_for(self, handle: str) -> str | None:
262
+ return self.contacts.get(handle.lower())
263
+
264
+ def mirror(self, event: str, **fields: object) -> None:
265
+ mirror(event, **fields)
266
+
267
+ def reload_contacts(self) -> int:
268
+ new = load_contacts()
269
+ self.contacts.clear()
270
+ self.contacts.update(new)
271
+ return len(self.contacts)
272
+
273
+ def relay_scope(self) -> dict[str, set[str]]:
274
+ return {
275
+ "self": set(SELF_HANDLES),
276
+ "handles": relay_handles(),
277
+ "groups": relay_groups(),
278
+ }
279
+
280
+
281
+ # ---------- integration registry ----------
282
+
283
+ INTEGRATIONS_DIR = Path(__file__).parent / "integrations"
284
+
285
+
286
+ def _looks_like_integration(cls: object) -> bool:
287
+ """Structural check used to find Integration classes in a module.
288
+
289
+ `runtime_checkable` Protocol's `isinstance` works on instances; we want
290
+ to find classes before instantiating them. The two attributes that
291
+ uniquely identify an Integration class are `NAME` and `SETTINGS_SCHEMA`.
292
+ """
293
+ return (
294
+ inspect.isclass(cls)
295
+ and isinstance(getattr(cls, "NAME", None), str)
296
+ and isinstance(getattr(cls, "SETTINGS_SCHEMA", None), dict)
297
+ )
298
+
299
+
300
+ def _classes_from_module(mod) -> list[type]:
301
+ """Return Integration-shaped classes defined in `mod`. Skips re-exports
302
+ (e.g. `from integrations.base import Integration`) by filtering on
303
+ __module__."""
304
+ out = []
305
+ for cls in vars(mod).values():
306
+ if _looks_like_integration(cls) and getattr(cls, "__module__", "") == mod.__name__:
307
+ out.append(cls)
308
+ return out
309
+
310
+
311
+ def _discover_integration_classes() -> dict[str, type]:
312
+ """Find every Integration class available to this install.
313
+
314
+ Two sources, merged with built-ins winning on name collisions:
315
+ 1. `integrations/<name>/` directories in this repo.
316
+ 2. `chatwire.integrations` entry points from pip-installed plugins.
317
+
318
+ Failures in one integration don't take down the others — log and skip.
319
+ """
320
+ out: dict[str, type] = {}
321
+
322
+ if INTEGRATIONS_DIR.is_dir():
323
+ for child in sorted(INTEGRATIONS_DIR.iterdir()):
324
+ if not child.is_dir() or child.name.startswith("_"):
325
+ continue
326
+ try:
327
+ mod = importlib.import_module(f"integrations.{child.name}")
328
+ except Exception:
329
+ log.exception("integration %s failed to import; skipping", child.name)
330
+ continue
331
+ for cls in _classes_from_module(mod):
332
+ out[cls.NAME] = cls
333
+
334
+ try:
335
+ eps = importlib.metadata.entry_points(group="chatwire.integrations")
336
+ except Exception:
337
+ eps = []
338
+ for ep in eps:
339
+ try:
340
+ cls = ep.load()
341
+ except Exception:
342
+ log.exception("entry point %s failed to load; skipping", ep.name)
343
+ continue
344
+ if not _looks_like_integration(cls):
345
+ log.warning("entry point %s does not look like an Integration "
346
+ "(missing NAME/SETTINGS_SCHEMA); skipping", ep.name)
347
+ continue
348
+ if cls.NAME in out:
349
+ log.info("entry point %s: %s already registered as built-in; skipping",
350
+ ep.name, cls.NAME)
351
+ continue
352
+ out[cls.NAME] = cls
353
+
354
+ return out
355
+
356
+
357
+ def _validate_block(name: str, block: dict, schema: dict) -> None:
358
+ """Validate `block` against `schema`. Fail fast at startup with a clear
359
+ error — half-configured integrations cause baffling runtime crashes."""
360
+ try:
361
+ import jsonschema # type: ignore
362
+ except ImportError:
363
+ log.warning("jsonschema not installed; skipping settings validation for %s", name)
364
+ return
365
+ try:
366
+ jsonschema.validate(block, schema)
367
+ except jsonschema.ValidationError as e: # type: ignore[attr-defined]
368
+ path = ".".join(str(p) for p in e.absolute_path) or "<root>"
369
+ raise SystemExit(
370
+ f"integration {name!r}: invalid config at {path}: {e.message}"
371
+ ) from e
372
+
373
+
374
+ def _build_integrations(cfg: dict) -> list:
375
+ """Instantiate every enabled integration found via discovery.
376
+
377
+ `enabled: true` in the integration's config block opts it in. A class
378
+ with no block in config (or `enabled: false`) is skipped silently — that
379
+ way a third-party plugin's mere presence on disk doesn't run it.
380
+ """
381
+ classes = _discover_integration_classes()
382
+ int_cfg = cfg.get("integrations") or {}
383
+ out: list = []
384
+ for name in sorted(classes):
385
+ cls = classes[name]
386
+ block = int_cfg.get(name) or {}
387
+ if not block.get("enabled"):
388
+ continue
389
+ _validate_block(name, block, cls.SETTINGS_SCHEMA)
390
+ try:
391
+ out.append(cls(block))
392
+ except Exception:
393
+ log.exception("integration %s: failed to instantiate; skipping", name)
394
+ return out
395
+
396
+
397
+ # ---------- poll loop ----------
398
+
399
+ async def poll_loop(reader: ChatDBReader, integrations: list) -> None:
400
+ log.info("poll loop starting (interval=%.1fs, integrations=%s, relay_handles=%s)",
401
+ POLL_INTERVAL_S,
402
+ [getattr(i, "NAME", "?") for i in integrations],
403
+ sorted(relay_handles()))
404
+ while True:
405
+ try:
406
+ messages = reader.poll()
407
+ for batch in _group_consecutive(messages):
408
+ if len(batch) == 1:
409
+ msg = batch[0]
410
+ else:
411
+ head = batch[0]
412
+ msg = InboundMessage(
413
+ rowid=batch[-1].rowid,
414
+ handle=head.handle,
415
+ text="\n".join(m.text.strip() for m in batch if m.text.strip()),
416
+ attachments=[],
417
+ is_from_me=head.is_from_me,
418
+ chat_guid=head.chat_guid,
419
+ chat_identifier=head.chat_identifier,
420
+ chat_name=head.chat_name,
421
+ is_group=head.is_group,
422
+ )
423
+ # Mirror inbound centrally: integrations only mirror their
424
+ # own outbound. Otherwise N integrations would each log the
425
+ # same inbound row N times.
426
+ mirror("inbound", handle=msg.handle, is_from_me=msg.is_from_me,
427
+ text=msg.text,
428
+ attachments=[str(a.path) for a in msg.attachments],
429
+ reply_to=msg.parent_handle or None,
430
+ chat_guid=msg.chat_guid or None,
431
+ chat_name=msg.chat_name or None)
432
+ for integ in integrations:
433
+ try:
434
+ await integ.on_inbound(msg)
435
+ except Exception:
436
+ log.exception("integration %s on_inbound failed",
437
+ getattr(integ, "NAME", "?"))
438
+ except Exception:
439
+ log.exception("poll iteration failed; sleeping and retrying")
440
+ await asyncio.sleep(POLL_INTERVAL_S)
441
+
442
+
443
+ # ---------- main ----------
444
+
445
+ async def amain() -> None:
446
+ if not (SELF_HANDLES or wl_all()):
447
+ raise SystemExit(
448
+ "SELF_HANDLES or WHITELIST_HANDLES must contain at least one handle"
449
+ )
450
+
451
+ reader = ChatDBReader(STATE_PATH)
452
+ reader.initialize_to_now()
453
+ contacts = load_contacts()
454
+ ctx = BridgeContextImpl(contacts=contacts, chatdb=reader)
455
+
456
+ integrations = _build_integrations(CFG)
457
+ if not integrations:
458
+ raise SystemExit(
459
+ "No integrations enabled. Run `chatwire web` and walk the "
460
+ "/setup wizard, or set integrations.<name>.enabled=true in "
461
+ "~/.chatwire/config.json."
462
+ )
463
+
464
+ log.info("starting; integrations=%s relay=%s",
465
+ [i.NAME for i in integrations], sorted(relay_handles()))
466
+
467
+ started: list = []
468
+ try:
469
+ for integ in integrations:
470
+ await integ.start(ctx)
471
+ started.append(integ)
472
+ await poll_loop(reader, integrations)
473
+ finally:
474
+ for integ in reversed(started):
475
+ try:
476
+ await integ.stop()
477
+ except Exception:
478
+ log.exception("integration %s stop failed",
479
+ getattr(integ, "NAME", "?"))
480
+
481
+
482
+ def main() -> None:
483
+ asyncio.run(amain())
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()