chatwire-mqtt 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-mqtt
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.).
|
|
5
|
+
Author: Allen Bina
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: chatwire,home-assistant,imessage,mqtt,node-red,plugin
|
|
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: paho-mqtt>=1.6
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# chatwire-mqtt\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# chatwire-mqtt\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""chatwire-mqtt — Publish every inbound iMessage to an MQTT broker,
|
|
2
|
+
and optionally send iMessages via an outbound MQTT topic.
|
|
3
|
+
|
|
4
|
+
Useful for home-automation pipelines (Home Assistant, Node-RED, OpenHAB)
|
|
5
|
+
and any subscriber that can speak MQTT.
|
|
6
|
+
|
|
7
|
+
Install with:
|
|
8
|
+
pipx inject chatwire chatwire-mqtt
|
|
9
|
+
# or: pip install chatwire-mqtt
|
|
10
|
+
|
|
11
|
+
Then add to config.json:
|
|
12
|
+
{
|
|
13
|
+
"integrations": {
|
|
14
|
+
"chatwire_mqtt": {
|
|
15
|
+
"enabled": true,
|
|
16
|
+
"host": "192.168.1.100",
|
|
17
|
+
"port": 1883,
|
|
18
|
+
"topic": "chatwire/messages",
|
|
19
|
+
"username": "mqttuser",
|
|
20
|
+
"password": "s3cr3t",
|
|
21
|
+
"qos": 0,
|
|
22
|
+
"use_tls": false,
|
|
23
|
+
"ca_cert": "",
|
|
24
|
+
"send_topic": "chatwire/send"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
TLS / encrypted brokers
|
|
30
|
+
-----------------------
|
|
31
|
+
Set ``use_tls: true`` to connect over TLS (typical port: 8883).
|
|
32
|
+
Leave ``ca_cert`` blank to verify with the system CA bundle, or set it to
|
|
33
|
+
the path of a PEM-formatted CA certificate file for self-signed brokers::
|
|
34
|
+
|
|
35
|
+
"use_tls": true,
|
|
36
|
+
"port": 8883,
|
|
37
|
+
"ca_cert": "/etc/ssl/certs/my-broker-ca.pem"
|
|
38
|
+
|
|
39
|
+
Published topic layout
|
|
40
|
+
-----------------------
|
|
41
|
+
1:1 message → <topic>/<sanitized_handle>
|
|
42
|
+
Group chat → <topic>/group/<sanitized_chat_identifier>
|
|
43
|
+
|
|
44
|
+
The JSON payload (v=1) schema:
|
|
45
|
+
{
|
|
46
|
+
"v": 1,
|
|
47
|
+
"rowid": 12345,
|
|
48
|
+
"handle": "+15551234567",
|
|
49
|
+
"text": "Hey!",
|
|
50
|
+
"is_from_me": false,
|
|
51
|
+
"chat": {
|
|
52
|
+
"guid": "iMessage;-;+15551234567",
|
|
53
|
+
"identifier": "+15551234567",
|
|
54
|
+
"name": null,
|
|
55
|
+
"is_group": false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Outbound relay (MQTT → iMessage)
|
|
60
|
+
---------------------------------
|
|
61
|
+
Set ``send_topic`` to a topic string (e.g. ``chatwire/send``) to subscribe
|
|
62
|
+
for outbound sends. Publish a JSON payload to that topic and chatwire will
|
|
63
|
+
send an iMessage on your behalf::
|
|
64
|
+
|
|
65
|
+
# 1:1 message
|
|
66
|
+
{"handle": "+15551234567", "text": "Hello from Node-RED!"}
|
|
67
|
+
|
|
68
|
+
# Group chat (use the chat GUID from the inbound payload's chat.guid)
|
|
69
|
+
{"chat": "iMessage;+;chat629...", "text": "Hi group!", "label": "My Group"}
|
|
70
|
+
|
|
71
|
+
Both ``handle`` and ``text`` (or ``chat`` and ``text``) are required.
|
|
72
|
+
``label`` is optional and used only for logging.
|
|
73
|
+
|
|
74
|
+
Leave ``send_topic`` blank (the default) to disable inbound subscriptions.
|
|
75
|
+
"""
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import asyncio
|
|
79
|
+
import json
|
|
80
|
+
import logging
|
|
81
|
+
import re
|
|
82
|
+
from typing import Any
|
|
83
|
+
|
|
84
|
+
log = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Lazy imports: integrations.base is only available inside the chatwire bridge.
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
try:
|
|
90
|
+
from integrations.base import BridgeContext, InboundMessage, SendTarget # type: ignore[import]
|
|
91
|
+
except ImportError: # pragma: no cover
|
|
92
|
+
BridgeContext = object # type: ignore[misc,assignment]
|
|
93
|
+
InboundMessage = object # type: ignore[misc,assignment]
|
|
94
|
+
SendTarget = None # type: ignore[assignment]
|
|
95
|
+
|
|
96
|
+
# paho-mqtt is optional at import time so unit tests can import this module
|
|
97
|
+
# without the library installed.
|
|
98
|
+
try:
|
|
99
|
+
import paho.mqtt.client as _paho # type: ignore[import]
|
|
100
|
+
_PAHO_AVAILABLE = True
|
|
101
|
+
except ImportError: # pragma: no cover
|
|
102
|
+
_paho = None # type: ignore[assignment]
|
|
103
|
+
_PAHO_AVAILABLE = False
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
from web import log_stream as _ls # type: ignore[import]
|
|
107
|
+
_HAS_LOG_STREAM = True
|
|
108
|
+
except ImportError: # pragma: no cover
|
|
109
|
+
_ls = None # type: ignore[assignment]
|
|
110
|
+
_HAS_LOG_STREAM = False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _log_send_future_error(fut: Any, label: str) -> None:
|
|
114
|
+
"""Done-callback for run_coroutine_threadsafe futures.
|
|
115
|
+
|
|
116
|
+
Logs BroadcastBlockedError / RateLimitError so silent swallowing of
|
|
117
|
+
anti-spam blocks is avoided. Never raises (callbacks must not raise).
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
exc = fut.exception()
|
|
121
|
+
except Exception:
|
|
122
|
+
return
|
|
123
|
+
if exc is None:
|
|
124
|
+
return
|
|
125
|
+
# Import lazily — chat_send is not available in plugin unit-test env.
|
|
126
|
+
try:
|
|
127
|
+
from chat_send import BroadcastBlockedError, RateLimitError # noqa: PLC0415
|
|
128
|
+
except ImportError:
|
|
129
|
+
log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
|
|
130
|
+
return
|
|
131
|
+
if isinstance(exc, BroadcastBlockedError):
|
|
132
|
+
log.error(
|
|
133
|
+
"%s blocked by anti-spam fuse (step=%d): %s",
|
|
134
|
+
label, exc.step, exc,
|
|
135
|
+
)
|
|
136
|
+
_ls_error("mqtt", f"outbound blocked: {exc}")
|
|
137
|
+
elif isinstance(exc, RateLimitError):
|
|
138
|
+
log.warning("%s rate-limited: %s", label, exc)
|
|
139
|
+
_ls_warn("mqtt", f"outbound rate-limited: {exc}")
|
|
140
|
+
else:
|
|
141
|
+
log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
|
|
142
|
+
_ls_error("mqtt", f"outbound error: {type(exc).__name__}: {exc}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ls_info(tag: str, msg: str) -> None:
|
|
146
|
+
if _HAS_LOG_STREAM and _ls is not None:
|
|
147
|
+
_ls.info(tag, msg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _ls_warn(tag: str, msg: str) -> None:
|
|
151
|
+
if _HAS_LOG_STREAM and _ls is not None:
|
|
152
|
+
_ls.warn(tag, msg)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ls_error(tag: str, msg: str) -> None:
|
|
156
|
+
if _HAS_LOG_STREAM and _ls is not None:
|
|
157
|
+
_ls.error(tag, msg)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# MQTT topic segments may not contain +, #, NUL, or / (per spec §4.7).
|
|
161
|
+
_UNSAFE_TOPIC = re.compile(r'[+#/\x00]')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _sanitize_topic_segment(s: str) -> str:
|
|
165
|
+
"""Replace MQTT-reserved characters with underscores for safe topic use."""
|
|
166
|
+
return _UNSAFE_TOPIC.sub('_', s) or "_"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class MQTTIntegration:
|
|
170
|
+
"""Publish every inbound iMessage to an MQTT broker.
|
|
171
|
+
|
|
172
|
+
Each message is serialised as JSON and published to:
|
|
173
|
+
<topic>/<handle> (1:1 conversations)
|
|
174
|
+
<topic>/group/<chat_id> (group chats)
|
|
175
|
+
|
|
176
|
+
Handle and chat_id are sanitized to replace MQTT wildcard characters
|
|
177
|
+
(+, #, /) with underscores.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
NAME = "chatwire_mqtt"
|
|
181
|
+
TIER = "official"
|
|
182
|
+
DISPLAY_NAME = "MQTT"
|
|
183
|
+
DESCRIPTION = "Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.)"
|
|
184
|
+
ICON = "📡"
|
|
185
|
+
|
|
186
|
+
SETTINGS_SCHEMA: dict[str, Any] = {
|
|
187
|
+
"type": "object",
|
|
188
|
+
"properties": {
|
|
189
|
+
"enabled": {
|
|
190
|
+
"type": "boolean",
|
|
191
|
+
"default": False,
|
|
192
|
+
"title": "Enable MQTT integration",
|
|
193
|
+
"x-ui-order": 0,
|
|
194
|
+
},
|
|
195
|
+
"host": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"title": "Broker host",
|
|
198
|
+
"description": "Hostname or IP of your MQTT broker.",
|
|
199
|
+
"x-ui-placeholder": "192.168.1.100",
|
|
200
|
+
"x-ui-order": 1,
|
|
201
|
+
},
|
|
202
|
+
"port": {
|
|
203
|
+
"type": "integer",
|
|
204
|
+
"default": 1883,
|
|
205
|
+
"minimum": 1,
|
|
206
|
+
"maximum": 65535,
|
|
207
|
+
"title": "Broker port",
|
|
208
|
+
"x-ui-order": 2,
|
|
209
|
+
},
|
|
210
|
+
"topic": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"default": "chatwire/messages",
|
|
213
|
+
"title": "Base topic",
|
|
214
|
+
"description": (
|
|
215
|
+
"Messages are published to <topic>/<handle> (1:1) "
|
|
216
|
+
"or <topic>/group/<chat_id> (group)."
|
|
217
|
+
),
|
|
218
|
+
"x-ui-order": 3,
|
|
219
|
+
},
|
|
220
|
+
"username": {
|
|
221
|
+
"type": "string",
|
|
222
|
+
"default": "",
|
|
223
|
+
"title": "Username (optional)",
|
|
224
|
+
"x-ui-order": 4,
|
|
225
|
+
},
|
|
226
|
+
"password": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"default": "",
|
|
229
|
+
"title": "Password (optional)",
|
|
230
|
+
"x-ui-type": "password",
|
|
231
|
+
"x-ui-order": 5,
|
|
232
|
+
},
|
|
233
|
+
"qos": {
|
|
234
|
+
"type": "integer",
|
|
235
|
+
"default": 0,
|
|
236
|
+
"enum": [0, 1, 2],
|
|
237
|
+
"title": "QoS level",
|
|
238
|
+
"description": "0 = at-most-once, 1 = at-least-once, 2 = exactly-once.",
|
|
239
|
+
"x-ui-order": 6,
|
|
240
|
+
},
|
|
241
|
+
"client_id": {
|
|
242
|
+
"type": "string",
|
|
243
|
+
"default": "chatwire",
|
|
244
|
+
"title": "Client ID (optional)",
|
|
245
|
+
"description": "MQTT client identifier. Must be unique on the broker.",
|
|
246
|
+
"x-ui-order": 7,
|
|
247
|
+
},
|
|
248
|
+
"use_tls": {
|
|
249
|
+
"type": "boolean",
|
|
250
|
+
"default": False,
|
|
251
|
+
"title": "Use TLS/SSL",
|
|
252
|
+
"description": (
|
|
253
|
+
"Encrypt the broker connection with TLS. "
|
|
254
|
+
"Set port to 8883 for standard MQTT-over-TLS."
|
|
255
|
+
),
|
|
256
|
+
"x-ui-order": 8,
|
|
257
|
+
},
|
|
258
|
+
"ca_cert": {
|
|
259
|
+
"type": "string",
|
|
260
|
+
"default": "",
|
|
261
|
+
"title": "CA certificate path (optional)",
|
|
262
|
+
"description": (
|
|
263
|
+
"Absolute path to a PEM CA certificate file. "
|
|
264
|
+
"Leave blank to use the system CA bundle."
|
|
265
|
+
),
|
|
266
|
+
"x-ui-placeholder": "/etc/ssl/certs/broker-ca.pem",
|
|
267
|
+
"x-ui-order": 9,
|
|
268
|
+
},
|
|
269
|
+
"send_topic": {
|
|
270
|
+
"type": "string",
|
|
271
|
+
"default": "",
|
|
272
|
+
"title": "Outbound send topic (optional)",
|
|
273
|
+
"description": (
|
|
274
|
+
"Subscribe to this topic to send iMessages from automations. "
|
|
275
|
+
"Payload: {\"handle\": \"+1...\", \"text\": \"...\"} for 1:1, "
|
|
276
|
+
"or {\"chat\": \"iMessage;+;...\", \"text\": \"...\"} for groups. "
|
|
277
|
+
"Leave blank to disable."
|
|
278
|
+
),
|
|
279
|
+
"x-ui-placeholder": "chatwire/send",
|
|
280
|
+
"x-ui-order": 10,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
"required": ["host"],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
287
|
+
self._host: str = config.get("host") or ""
|
|
288
|
+
self._port: int = int(config.get("port") or 1883)
|
|
289
|
+
self._topic: str = (config.get("topic") or "chatwire/messages").rstrip("/")
|
|
290
|
+
self._username: str = config.get("username") or ""
|
|
291
|
+
self._password: str = config.get("password") or ""
|
|
292
|
+
self._qos: int = int(config.get("qos") or 0)
|
|
293
|
+
self._client_id: str = config.get("client_id") or "chatwire"
|
|
294
|
+
self._use_tls: bool = bool(config.get("use_tls", False))
|
|
295
|
+
self._ca_cert: str = config.get("ca_cert") or ""
|
|
296
|
+
self._send_topic: str = config.get("send_topic") or ""
|
|
297
|
+
self._client: Any = None # paho.mqtt.client.Client instance
|
|
298
|
+
self._ctx: Any = None # BridgeContext stashed in start()
|
|
299
|
+
self._loop: asyncio.AbstractEventLoop | None = None # bridge event loop
|
|
300
|
+
|
|
301
|
+
async def start(self, ctx: Any) -> None:
|
|
302
|
+
if not self._host:
|
|
303
|
+
raise ValueError("chatwire_mqtt: 'host' is required")
|
|
304
|
+
if not _PAHO_AVAILABLE:
|
|
305
|
+
raise RuntimeError(
|
|
306
|
+
"chatwire_mqtt: paho-mqtt is not installed. "
|
|
307
|
+
"Run: pip install paho-mqtt"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
client = _paho.Client(client_id=self._client_id)
|
|
311
|
+
if self._username:
|
|
312
|
+
client.username_pw_set(self._username, self._password or None)
|
|
313
|
+
|
|
314
|
+
if self._use_tls:
|
|
315
|
+
try:
|
|
316
|
+
client.tls_set(ca_certs=self._ca_cert or None)
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
raise RuntimeError(
|
|
319
|
+
f"chatwire_mqtt: TLS setup failed: {exc}"
|
|
320
|
+
) from exc
|
|
321
|
+
|
|
322
|
+
self._ctx = ctx
|
|
323
|
+
self._loop = asyncio.get_event_loop()
|
|
324
|
+
|
|
325
|
+
# on_connect / on_disconnect for logging.
|
|
326
|
+
def _on_connect(c: Any, userdata: Any, flags: Any, rc: int) -> None:
|
|
327
|
+
if rc == 0:
|
|
328
|
+
log.info("mqtt: connected to %s:%d", self._host, self._port)
|
|
329
|
+
_ls_info("mqtt", f"connected to {self._host}:{self._port}")
|
|
330
|
+
if self._send_topic:
|
|
331
|
+
c.subscribe(self._send_topic)
|
|
332
|
+
log.info("mqtt: subscribed to send_topic=%s", self._send_topic)
|
|
333
|
+
_ls_info("mqtt", f"subscribed to send_topic={self._send_topic}")
|
|
334
|
+
else:
|
|
335
|
+
log.warning("mqtt: connect failed rc=%d", rc)
|
|
336
|
+
_ls_warn("mqtt", f"connect failed rc={rc}")
|
|
337
|
+
|
|
338
|
+
def _on_disconnect(c: Any, userdata: Any, rc: int) -> None:
|
|
339
|
+
if rc != 0:
|
|
340
|
+
log.warning("mqtt: unexpected disconnect rc=%d", rc)
|
|
341
|
+
|
|
342
|
+
client.on_connect = _on_connect
|
|
343
|
+
client.on_disconnect = _on_disconnect
|
|
344
|
+
if self._send_topic:
|
|
345
|
+
client.on_message = self._on_outbound_message
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
client.connect(self._host, self._port, keepalive=60)
|
|
349
|
+
except OSError as exc:
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
f"chatwire_mqtt: cannot connect to {self._host}:{self._port}: {exc}"
|
|
352
|
+
) from exc
|
|
353
|
+
|
|
354
|
+
client.loop_start()
|
|
355
|
+
self._client = client
|
|
356
|
+
log.info(
|
|
357
|
+
"mqtt integration started; broker=%s:%d topic=%s qos=%d tls=%s send_topic=%r",
|
|
358
|
+
self._host, self._port, self._topic, self._qos, self._use_tls, self._send_topic,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def stop(self) -> None:
|
|
362
|
+
if self._client is not None:
|
|
363
|
+
try:
|
|
364
|
+
self._client.loop_stop()
|
|
365
|
+
self._client.disconnect()
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
self._client = None
|
|
369
|
+
self._ctx = None
|
|
370
|
+
self._loop = None
|
|
371
|
+
log.info("mqtt integration stopped")
|
|
372
|
+
|
|
373
|
+
async def on_inbound(self, msg: Any) -> None:
|
|
374
|
+
"""Publish the inbound message as JSON to the configured MQTT topic."""
|
|
375
|
+
if self._client is None:
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
is_group = bool(getattr(msg, "is_group", False))
|
|
379
|
+
handle = getattr(msg, "handle", "") or ""
|
|
380
|
+
chat_identifier = getattr(msg, "chat_identifier", "") or handle
|
|
381
|
+
|
|
382
|
+
if is_group:
|
|
383
|
+
seg = _sanitize_topic_segment(chat_identifier)
|
|
384
|
+
full_topic = f"{self._topic}/group/{seg}"
|
|
385
|
+
else:
|
|
386
|
+
seg = _sanitize_topic_segment(handle)
|
|
387
|
+
full_topic = f"{self._topic}/{seg}"
|
|
388
|
+
|
|
389
|
+
payload = self._payload_for(msg)
|
|
390
|
+
body = json.dumps(payload, ensure_ascii=False, default=str)
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
result = self._client.publish(full_topic, body, qos=self._qos)
|
|
394
|
+
# result.rc == 0 (MQTT_ERR_SUCCESS) on success.
|
|
395
|
+
if result.rc != 0:
|
|
396
|
+
log.warning("mqtt: publish to %s failed rc=%d", full_topic, result.rc)
|
|
397
|
+
_ls_warn("mqtt", f"publish {full_topic} failed rc={result.rc}")
|
|
398
|
+
else:
|
|
399
|
+
_ls_info("mqtt", f"publish → {full_topic}")
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
log.warning("mqtt: publish failed: %s: %s", type(exc).__name__, exc)
|
|
402
|
+
_ls_error("mqtt", f"publish failed: {type(exc).__name__}: {exc}")
|
|
403
|
+
|
|
404
|
+
def _on_outbound_message(self, client: Any, userdata: Any, message: Any) -> None:
|
|
405
|
+
"""Handle an MQTT message on send_topic and relay it as an iMessage.
|
|
406
|
+
|
|
407
|
+
Called by paho on its network thread. Parses the JSON payload,
|
|
408
|
+
builds a SendTarget, and schedules ctx.send_text() on the bridge loop.
|
|
409
|
+
|
|
410
|
+
Payload (1:1): {"handle": "+15551234567", "text": "Hello!"}
|
|
411
|
+
Payload (group): {"chat": "iMessage;+;chat123", "text": "Hi!", "label": "My Group"}
|
|
412
|
+
|
|
413
|
+
``text`` and either ``handle`` or ``chat`` are required.
|
|
414
|
+
``label`` is optional (used for logging only).
|
|
415
|
+
"""
|
|
416
|
+
if self._ctx is None or SendTarget is None:
|
|
417
|
+
return # pragma: no cover
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
payload = json.loads(message.payload)
|
|
421
|
+
except (json.JSONDecodeError, Exception) as exc:
|
|
422
|
+
log.warning("mqtt: bad outbound payload: %s", exc)
|
|
423
|
+
_ls_warn("mqtt", f"bad outbound payload: {exc}")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
text = (payload.get("text") or "").strip()
|
|
427
|
+
if not text:
|
|
428
|
+
log.debug("mqtt: outbound message has no text; ignored")
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
handle = (payload.get("handle") or "").strip()
|
|
432
|
+
chat_guid = (payload.get("chat") or "").strip()
|
|
433
|
+
label = (payload.get("label") or "").strip()
|
|
434
|
+
|
|
435
|
+
if handle:
|
|
436
|
+
target = SendTarget(
|
|
437
|
+
kind="handle",
|
|
438
|
+
value=handle,
|
|
439
|
+
label=label or (self._ctx.name_for(handle) if hasattr(self._ctx, "name_for") else None) or handle,
|
|
440
|
+
)
|
|
441
|
+
log.debug("mqtt: outbound → handle=%s text=%r", handle, text[:80])
|
|
442
|
+
_ls_info("mqtt", f"outbound → handle={handle}")
|
|
443
|
+
elif chat_guid:
|
|
444
|
+
target = SendTarget(
|
|
445
|
+
kind="chat",
|
|
446
|
+
value=chat_guid,
|
|
447
|
+
label=label or chat_guid,
|
|
448
|
+
)
|
|
449
|
+
log.debug("mqtt: outbound → chat=%s text=%r", chat_guid, text[:80])
|
|
450
|
+
_ls_info("mqtt", f"outbound → chat={chat_guid}")
|
|
451
|
+
else:
|
|
452
|
+
log.warning("mqtt: outbound payload missing 'handle' or 'chat'; ignored")
|
|
453
|
+
_ls_warn("mqtt", "outbound payload missing 'handle' or 'chat'")
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
loop = self._loop or asyncio.get_event_loop()
|
|
457
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
458
|
+
self._ctx.send_text(target, text), loop
|
|
459
|
+
)
|
|
460
|
+
label = f"mqtt:{handle or chat_guid}"
|
|
461
|
+
fut.add_done_callback(lambda f: _log_send_future_error(f, label))
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def _payload_for(msg: Any) -> dict[str, Any]:
|
|
465
|
+
"""Serialise an InboundMessage to a stable, versioned dict (v=1)."""
|
|
466
|
+
chat_guid = getattr(msg, "chat_guid", None)
|
|
467
|
+
chat_identifier = getattr(msg, "chat_identifier", None)
|
|
468
|
+
chat_name = getattr(msg, "chat_name", None)
|
|
469
|
+
is_group = bool(getattr(msg, "is_group", False))
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
"v": 1,
|
|
473
|
+
"rowid": getattr(msg, "rowid", None),
|
|
474
|
+
"handle": getattr(msg, "handle", "") or "",
|
|
475
|
+
"text": getattr(msg, "text", "") or "",
|
|
476
|
+
"is_from_me": bool(getattr(msg, "is_from_me", False)),
|
|
477
|
+
"chat": (
|
|
478
|
+
{
|
|
479
|
+
"guid": chat_guid,
|
|
480
|
+
"identifier": chat_identifier,
|
|
481
|
+
"name": chat_name,
|
|
482
|
+
"is_group": is_group,
|
|
483
|
+
}
|
|
484
|
+
if chat_guid
|
|
485
|
+
else None
|
|
486
|
+
),
|
|
487
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chatwire-mqtt"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Allen Bina" }]
|
|
13
|
+
keywords = ["chatwire", "mqtt", "imessage", "home-assistant", "node-red", "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
|
+
"paho-mqtt>=1.6",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.entry-points."chatwire.integrations"]
|
|
27
|
+
chatwire_mqtt = "chatwire_mqtt:MQTTIntegration"
|