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"
|