chatwire-xmpp 1.0.0__tar.gz

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,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatwire-xmpp
3
+ Version: 1.0.0
4
+ Summary: XMPP relay integration for chatwire — bridge iMessage ↔ XMPP (1:1, whitelisted contacts).
5
+ Author: Allen Bina
6
+ License: MIT
7
+ Keywords: chatwire,imessage,jabber,plugin,xmpp
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: slixmpp>=1.8
16
+ Description-Content-Type: text/markdown
17
+
18
+ # chatwire-xmpp\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1 @@
1
+ # chatwire-xmpp\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1,320 @@
1
+ """chatwire-xmpp — XMPP relay integration for chatwire.
2
+
3
+ Bridges iMessage ↔ XMPP (Jabber) for whitelisted contacts (1:1 only, MVP).
4
+ Install with:
5
+ pip install chatwire-xmpp
6
+
7
+ Then add to config.json:
8
+ {
9
+ "integrations": {
10
+ "chatwire_xmpp": {
11
+ "enabled": true,
12
+ "jid": "bridge@example.com",
13
+ "password": "s3cr3t",
14
+ "server_url": "xmpp.example.com", // optional; defaults to JID domain
15
+ "contact_mappings": [
16
+ {"imessage_handle": "+15551234567", "xmpp_jid": "alice@example.com"},
17
+ {"imessage_handle": "bob@icloud.com", "xmpp_jid": "bob@example.com"}
18
+ ]
19
+ }
20
+ }
21
+ }
22
+
23
+ Messages flow:
24
+ iMessage → on_inbound() → sends to mapped XMPP JID via slixmpp
25
+ XMPP inbound handler → relays text to iMessage via ctx.send_text()
26
+
27
+ Only handles mapped (whitelisted) contacts; unknown senders are silently ignored.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import threading
34
+ from typing import Any
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Anti-spam error logging helper
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _log_send_future_error(fut: Any, label: str) -> None:
44
+ """Done-callback for run_coroutine_threadsafe futures.
45
+
46
+ Logs BroadcastBlockedError / RateLimitError so anti-spam blocks are not
47
+ silently swallowed. Never raises.
48
+ """
49
+ try:
50
+ exc = fut.exception()
51
+ except Exception:
52
+ return
53
+ if exc is None:
54
+ return
55
+ try:
56
+ from chat_send import BroadcastBlockedError, RateLimitError # noqa: PLC0415
57
+ except ImportError:
58
+ log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
59
+ return
60
+ if isinstance(exc, BroadcastBlockedError):
61
+ log.error(
62
+ "%s blocked by anti-spam fuse (step=%d): %s",
63
+ label, exc.step, exc,
64
+ )
65
+ elif isinstance(exc, RateLimitError):
66
+ log.warning("%s rate-limited: %s", label, exc)
67
+ else:
68
+ log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Lazy imports: integrations.base only available inside the chatwire install.
73
+ # ---------------------------------------------------------------------------
74
+ try:
75
+ from integrations.base import BridgeContext, InboundMessage, SendTarget # type: ignore[import]
76
+ except ImportError: # pragma: no cover
77
+ BridgeContext = object # type: ignore[misc,assignment]
78
+ InboundMessage = object # type: ignore[misc,assignment]
79
+ SendTarget = None # type: ignore[misc,assignment]
80
+
81
+ # slixmpp is optional at import time so unit tests can import this module
82
+ # without the library installed.
83
+ try:
84
+ import slixmpp # type: ignore[import]
85
+ from slixmpp import ClientXMPP # type: ignore[import]
86
+ _SLIXMPP_AVAILABLE = True
87
+ except ImportError: # pragma: no cover
88
+ slixmpp = None # type: ignore[assignment]
89
+ ClientXMPP = object # type: ignore[misc,assignment]
90
+ _SLIXMPP_AVAILABLE = False
91
+
92
+
93
+ class XMPPIntegration:
94
+ """Bridge iMessage ↔ XMPP for a whitelisted set of contacts.
95
+
96
+ Config keys
97
+ -----------
98
+ jid : str
99
+ Full JID used by the bridge bot, e.g. ``bridge@example.com``.
100
+ password : str
101
+ Password for the XMPP account.
102
+ server_url : str, optional
103
+ XMPP server hostname. Defaults to the domain part of *jid*.
104
+ contact_mappings : list[dict]
105
+ Each entry must have ``imessage_handle`` and ``xmpp_jid``.
106
+ """
107
+
108
+ NAME = "chatwire_xmpp"
109
+ TIER = "official" # Reviewed bridge; needs full message text for relay.
110
+ DISPLAY_NAME = "XMPP Relay"
111
+ DESCRIPTION = "Bridge iMessage ↔ XMPP (Jabber) for whitelisted contacts."
112
+ ICON = "💬"
113
+
114
+ SETTINGS_SCHEMA: dict[str, Any] = {
115
+ "type": "object",
116
+ "properties": {
117
+ "enabled": {
118
+ "type": "boolean",
119
+ "default": False,
120
+ "title": "Enable XMPP relay",
121
+ "x-ui-order": 0,
122
+ },
123
+ "jid": {
124
+ "type": "string",
125
+ "title": "Bridge JID",
126
+ "description": "Full JID for the bridge bot, e.g. bridge@example.com",
127
+ "x-ui-placeholder": "bridge@example.com",
128
+ "x-ui-order": 1,
129
+ },
130
+ "password": {
131
+ "type": "string",
132
+ "title": "Password",
133
+ "description": "Password for the bridge XMPP account.",
134
+ "x-ui-type": "password",
135
+ "x-ui-order": 2,
136
+ },
137
+ "server_url": {
138
+ "type": "string",
139
+ "title": "XMPP server (optional)",
140
+ "description": (
141
+ "Hostname of the XMPP server. Leave blank to use the "
142
+ "domain part of the JID."
143
+ ),
144
+ "x-ui-placeholder": "xmpp.example.com",
145
+ "x-ui-order": 3,
146
+ },
147
+ "contact_mappings": {
148
+ "type": "array",
149
+ "title": "Contact mappings",
150
+ "description": (
151
+ "Map iMessage handles to XMPP JIDs. Only mapped contacts "
152
+ "are relayed; all others are silently ignored."
153
+ ),
154
+ "x-ui-order": 4,
155
+ "items": {
156
+ "type": "object",
157
+ "properties": {
158
+ "imessage_handle": {
159
+ "type": "string",
160
+ "title": "iMessage handle",
161
+ "description": "Phone number (+E.164) or email.",
162
+ },
163
+ "xmpp_jid": {
164
+ "type": "string",
165
+ "title": "XMPP JID",
166
+ "description": "Bare JID of the XMPP contact.",
167
+ },
168
+ },
169
+ "required": ["imessage_handle", "xmpp_jid"],
170
+ },
171
+ "default": [],
172
+ },
173
+ },
174
+ "required": ["jid", "password"],
175
+ }
176
+
177
+ # ------------------------------------------------------------------
178
+
179
+ def __init__(self, config: dict[str, Any]) -> None:
180
+ self._jid: str = config.get("jid") or ""
181
+ self._password: str = config.get("password") or ""
182
+ self._server_url: str = config.get("server_url") or ""
183
+
184
+ mappings_raw: list[dict] = config.get("contact_mappings") or []
185
+
186
+ # imessage_handle → xmpp_jid (for outbound relay)
187
+ self._im_to_xmpp: dict[str, str] = {}
188
+ # xmpp_jid (bare, lower) → imessage_handle (for inbound relay)
189
+ self._xmpp_to_im: dict[str, str] = {}
190
+ for m in mappings_raw:
191
+ handle = (m.get("imessage_handle") or "").strip()
192
+ xjid = (m.get("xmpp_jid") or "").strip().lower()
193
+ if handle and xjid:
194
+ self._im_to_xmpp[handle] = xjid
195
+ self._xmpp_to_im[xjid] = handle
196
+
197
+ self._ctx: Any = None
198
+ self._xmpp: Any = None # slixmpp ClientXMPP instance
199
+
200
+ # ------------------------------------------------------------------
201
+ # Integration lifecycle
202
+ # ------------------------------------------------------------------
203
+
204
+ async def start(self, ctx: Any) -> None:
205
+ if not self._jid:
206
+ raise ValueError("chatwire_xmpp: 'jid' is required")
207
+ if not self._password:
208
+ raise ValueError("chatwire_xmpp: 'password' is required")
209
+ if not _SLIXMPP_AVAILABLE:
210
+ raise RuntimeError(
211
+ "chatwire_xmpp: slixmpp is not installed. "
212
+ "Run: pip install slixmpp"
213
+ )
214
+
215
+ self._ctx = ctx
216
+
217
+ # slixmpp uses its own asyncio event loop internally. We run
218
+ # connect/process on a dedicated thread so it doesn't block the
219
+ # bridge's main loop.
220
+ xmpp = ClientXMPP(self._jid, self._password)
221
+ xmpp.add_event_handler("session_start", self._on_session_start)
222
+ xmpp.add_event_handler("message", self._on_xmpp_message)
223
+ self._xmpp = xmpp
224
+
225
+ host = self._server_url or self._jid.split("@")[-1]
226
+ xmpp.connect((host, 5222))
227
+
228
+ # Run slixmpp's event loop in a background thread.
229
+ t = threading.Thread(
230
+ target=xmpp.process,
231
+ kwargs={"forever": True},
232
+ name="chatwire-xmpp-thread",
233
+ daemon=True,
234
+ )
235
+ t.start()
236
+
237
+ log.info(
238
+ "xmpp integration started; JID=%s, %d mapping(s)",
239
+ self._jid,
240
+ len(self._im_to_xmpp),
241
+ )
242
+
243
+ async def stop(self) -> None:
244
+ if self._xmpp is not None:
245
+ try:
246
+ self._xmpp.disconnect()
247
+ except Exception:
248
+ pass
249
+ self._xmpp = None
250
+ log.info("xmpp integration stopped")
251
+
252
+ # ------------------------------------------------------------------
253
+ # iMessage → XMPP
254
+ # ------------------------------------------------------------------
255
+
256
+ async def on_inbound(self, msg: Any) -> None:
257
+ """Relay an inbound iMessage to the mapped XMPP JID."""
258
+ if self._xmpp is None:
259
+ return
260
+
261
+ handle = getattr(msg, "handle", None) or ""
262
+ xjid = self._im_to_xmpp.get(handle)
263
+ if not xjid:
264
+ return # not a mapped contact; silently ignore
265
+
266
+ text = (getattr(msg, "text", None) or "").strip()
267
+ if not text:
268
+ return # no text to relay (photo-only messages unsupported in MVP)
269
+
270
+ try:
271
+ self._xmpp.send_message(mto=xjid, mbody=text, mtype="chat")
272
+ log.debug("xmpp: relayed iMessage from %s → %s", handle, xjid)
273
+ except Exception as exc:
274
+ log.warning("xmpp: failed to relay message: %s", exc)
275
+
276
+ # ------------------------------------------------------------------
277
+ # XMPP → iMessage
278
+ # ------------------------------------------------------------------
279
+
280
+ def _on_session_start(self, event: Any) -> None:
281
+ """Called by slixmpp when the XMPP session is established."""
282
+ try:
283
+ self._xmpp.send_presence()
284
+ log.info("xmpp: session started, presence sent")
285
+ except Exception as exc:
286
+ log.warning("xmpp: error sending presence: %s", exc)
287
+
288
+ def _on_xmpp_message(self, msg: Any) -> None:
289
+ """Called by slixmpp for every incoming XMPP message."""
290
+ if msg.get("type") not in ("chat", "normal"):
291
+ return
292
+
293
+ sender_jid = str(msg.get("from", "")).split("/")[0].lower()
294
+ im_handle = self._xmpp_to_im.get(sender_jid)
295
+ if not im_handle:
296
+ return # not a mapped contact
297
+
298
+ body = str(msg.get("body") or "").strip()
299
+ if not body:
300
+ return
301
+
302
+ if self._ctx is None or SendTarget is None:
303
+ return # pragma: no cover
304
+
305
+ target = SendTarget(
306
+ kind="handle",
307
+ value=im_handle,
308
+ label=self._ctx.name_for(im_handle) or im_handle,
309
+ )
310
+
311
+ # slixmpp runs in a thread; schedule the coroutine on the bridge loop.
312
+ loop: asyncio.AbstractEventLoop | None = getattr(
313
+ self._ctx, "_loop", None
314
+ ) or asyncio.get_event_loop()
315
+ fut = asyncio.run_coroutine_threadsafe(
316
+ self._ctx.send_text(target, body), loop
317
+ )
318
+ label = f"xmpp:{sender_jid}"
319
+ fut.add_done_callback(lambda f: _log_send_future_error(f, label))
320
+ log.debug("xmpp: relayed XMPP from %s → iMessage %s", sender_jid, im_handle)
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chatwire-xmpp"
7
+ version = "1.0.0"
8
+ description = "XMPP relay integration for chatwire — bridge iMessage ↔ XMPP (1:1, whitelisted contacts)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Allen Bina" }]
13
+ keywords = ["chatwire", "xmpp", "jabber", "imessage", "plugin"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "slixmpp>=1.8",
24
+ ]
25
+
26
+ [project.entry-points."chatwire.integrations"]
27
+ chatwire_xmpp = "chatwire_xmpp:XMPPIntegration"