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 +12 -0
- bridge.py +487 -0
- chat_db.py +393 -0
- chat_send.py +433 -0
- chatwire-0.2.0.dist-info/METADATA +228 -0
- chatwire-0.2.0.dist-info/RECORD +39 -0
- chatwire-0.2.0.dist-info/WHEEL +5 -0
- chatwire-0.2.0.dist-info/entry_points.txt +2 -0
- chatwire-0.2.0.dist-info/licenses/LICENSE +21 -0
- chatwire-0.2.0.dist-info/top_level.txt +14 -0
- chatwire_cli.py +317 -0
- config.py +233 -0
- contacts.py +145 -0
- echo_log.py +60 -0
- integrations/__init__.py +26 -0
- integrations/base.py +158 -0
- integrations/telegram/__init__.py +957 -0
- integrations/web/__init__.py +130 -0
- integrations/webhook/__init__.py +169 -0
- migrations/0001_initial.py +16 -0
- migrations/0002_integration_split.py +115 -0
- migrations/__init__.py +62 -0
- prefix.py +64 -0
- templates/__init__.py +0 -0
- templates/launchd/bridge.plist.template +56 -0
- templates/launchd/keepawake.plist.template +40 -0
- templates/launchd/web.plist.template +46 -0
- web/main.py +1280 -0
- web/setup_wizard.py +337 -0
- web/static/favicon.svg +4 -0
- web/static/style.css +676 -0
- web/static/sw.js +43 -0
- web/static/update-check.js +159 -0
- web/templates/_conversation.html +246 -0
- web/templates/_conversations.html +52 -0
- web/templates/_settings.html +53 -0
- web/templates/_whitelist_rows.html +28 -0
- web/templates/index.html +214 -0
- whitelist.py +149 -0
_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()
|