mmrelay 1.2.1__py3-none-any.whl → 1.2.3__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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +451 -48
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +193 -66
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +11 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1068 -292
- mmrelay/meshtastic_utils.py +352 -209
- mmrelay/message_queue.py +22 -23
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +44 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +323 -128
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +1 -1
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/METADATA +7 -7
- mmrelay-1.2.3.dist-info/RECORD +48 -0
- mmrelay-1.2.1.dist-info/RECORD +0 -45
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.1.dist-info → mmrelay-1.2.3.dist-info}/top_level.txt +0 -0
mmrelay/matrix_utils.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import getpass
|
|
3
3
|
import html
|
|
4
|
+
import importlib
|
|
4
5
|
import io
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
10
|
+
import ssl
|
|
9
11
|
import sys
|
|
10
12
|
import time
|
|
11
|
-
from typing import Any, Dict, Union
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
12
14
|
from urllib.parse import urlparse
|
|
13
15
|
|
|
14
|
-
import bleach
|
|
15
|
-
import markdown
|
|
16
16
|
import meshtastic.protobuf.portnums_pb2
|
|
17
17
|
from nio import (
|
|
18
18
|
AsyncClient,
|
|
@@ -104,6 +104,100 @@ from mmrelay.message_queue import get_message_queue, queue_message
|
|
|
104
104
|
logger = get_logger(name="Matrix")
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _is_room_alias(value: Any) -> bool:
|
|
108
|
+
"""Return True when value looks like a Matrix room alias (string starting with '#')."""
|
|
109
|
+
|
|
110
|
+
return isinstance(value, str) and value.startswith("#")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _iter_room_alias_entries(mapping):
|
|
114
|
+
"""
|
|
115
|
+
Yield (alias_or_id, setter) pairs for entries in a Matrix room mapping.
|
|
116
|
+
|
|
117
|
+
Each yielded tuple contains:
|
|
118
|
+
- alias_or_id (str): the room alias or room ID found in the entry (may be an alias starting with '#' or a canonical room ID). If a dict entry has no `"id"` key, an empty string is yielded.
|
|
119
|
+
- setter (callable): a function accepting a single argument `new_id` which updates the underlying mapping in-place to replace the alias with the resolved room ID.
|
|
120
|
+
|
|
121
|
+
Supports two mapping shapes:
|
|
122
|
+
- list: items may be strings (alias/ID) or dicts with an `"id"` key.
|
|
123
|
+
- dict: values may be strings (alias/ID) or dicts with an `"id"` key.
|
|
124
|
+
|
|
125
|
+
The setter updates the original collection (list element or dict value) so callers can resolve aliases and persist resolved IDs back into the provided mapping.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
if isinstance(mapping, list):
|
|
129
|
+
for index, entry in enumerate(mapping):
|
|
130
|
+
if isinstance(entry, dict):
|
|
131
|
+
yield entry.get(
|
|
132
|
+
"id", ""
|
|
133
|
+
), lambda new_id, target=entry: target.__setitem__("id", new_id)
|
|
134
|
+
else:
|
|
135
|
+
yield entry, lambda new_id, idx=index, collection=mapping: collection.__setitem__(
|
|
136
|
+
idx, new_id
|
|
137
|
+
)
|
|
138
|
+
elif isinstance(mapping, dict):
|
|
139
|
+
for key, entry in list(mapping.items()):
|
|
140
|
+
if isinstance(entry, dict):
|
|
141
|
+
yield entry.get(
|
|
142
|
+
"id", ""
|
|
143
|
+
), lambda new_id, target=entry: target.__setitem__("id", new_id)
|
|
144
|
+
else:
|
|
145
|
+
yield entry, lambda new_id, target_key=key, collection=mapping: collection.__setitem__(
|
|
146
|
+
target_key, new_id
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _resolve_aliases_in_mapping(mapping, resolver):
|
|
151
|
+
"""
|
|
152
|
+
Resolve Matrix room alias entries found in a mapping (list or dict) by calling an async resolver and replacing aliases with resolved room IDs in-place.
|
|
153
|
+
|
|
154
|
+
This function iterates entries produced by _iter_room_alias_entries(mapping). For each entry whose key/value looks like a Matrix room alias (a string starting with '#'), it awaits the provided resolver coroutine with the alias; if the resolver returns a truthy room ID, the corresponding entry in the original mapping is updated via the entry's setter. If mapping is not a list or dict, the function logs a warning and returns without modifying anything.
|
|
155
|
+
|
|
156
|
+
Parameters:
|
|
157
|
+
mapping (list|dict): A mapping of Matrix rooms where some entries may be aliases (e.g., "#room:example.org").
|
|
158
|
+
resolver (Callable[[str], Awaitable[Optional[str]]]): Async callable that accepts an alias and returns a resolved room ID (or falsy on failure).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
None
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
if not isinstance(mapping, (list, dict)):
|
|
165
|
+
logger.warning(
|
|
166
|
+
"matrix_rooms is expected to be a list or dict, got %s",
|
|
167
|
+
type(mapping).__name__,
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
for alias, setter in _iter_room_alias_entries(mapping):
|
|
172
|
+
if _is_room_alias(alias):
|
|
173
|
+
resolved_id = await resolver(alias)
|
|
174
|
+
if resolved_id:
|
|
175
|
+
setter(resolved_id)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _update_room_id_in_mapping(mapping, alias, resolved_id) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Replace a room alias with its resolved room ID in a mapping.
|
|
181
|
+
|
|
182
|
+
Parameters:
|
|
183
|
+
mapping (list|dict): A matrix_rooms mapping represented as a list of aliases or a dict of entries; only list and dict types are supported.
|
|
184
|
+
alias (str): The room alias to replace (e.g., "#room:server").
|
|
185
|
+
resolved_id (str): The canonical room ID to substitute for the alias (e.g., "!abcdef:server").
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
bool: True if the alias was found and replaced with resolved_id; False if the mapping type is unsupported or the alias was not present.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
if not isinstance(mapping, (list, dict)):
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
for existing_alias, setter in _iter_room_alias_entries(mapping):
|
|
195
|
+
if existing_alias == alias:
|
|
196
|
+
setter(resolved_id)
|
|
197
|
+
return True
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
107
201
|
def _display_room_channel_mappings(
|
|
108
202
|
rooms: Dict[str, Any], config: Dict[str, Any], e2ee_status: Dict[str, Any]
|
|
109
203
|
) -> None:
|
|
@@ -212,6 +306,72 @@ def _can_auto_create_credentials(matrix_config: dict) -> bool:
|
|
|
212
306
|
return all(isinstance(v, str) and v.strip() for v in (homeserver, user, password))
|
|
213
307
|
|
|
214
308
|
|
|
309
|
+
def _normalize_bot_user_id(homeserver: str, bot_user_id: str) -> str:
|
|
310
|
+
"""
|
|
311
|
+
Normalize a bot user identifier into a full Matrix MXID.
|
|
312
|
+
|
|
313
|
+
Accepts several common input forms and returns a normalized Matrix ID of the form
|
|
314
|
+
"@localpart:server". Behavior:
|
|
315
|
+
- If bot_user_id is falsy, it is returned unchanged.
|
|
316
|
+
- If bot_user_id already contains a server part (e.g. "@user:server.com" or "user:server.com"),
|
|
317
|
+
the existing server is preserved (any trailing numeric port is removed).
|
|
318
|
+
- If bot_user_id lacks a server part (e.g. "@user" or "user"), the server domain is derived
|
|
319
|
+
from the provided homeserver and appended.
|
|
320
|
+
- The homeserver argument is tolerant of missing URL scheme and will extract the hostname
|
|
321
|
+
portion (handles inputs like "example.com", "https://example.com:8448", or
|
|
322
|
+
"[::1]:8448/path").
|
|
323
|
+
|
|
324
|
+
Parameters:
|
|
325
|
+
homeserver (str): The Matrix homeserver URL or host used to derive a server domain.
|
|
326
|
+
bot_user_id (str): A bot identifier in one of several forms (with or without leading "@"
|
|
327
|
+
and with or without a server part).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
str: A normalized Matrix user ID in the form "@localpart:server".
|
|
331
|
+
"""
|
|
332
|
+
if not bot_user_id:
|
|
333
|
+
return bot_user_id
|
|
334
|
+
|
|
335
|
+
def _canonical_server(value: str | None) -> str | None:
|
|
336
|
+
if not value:
|
|
337
|
+
return value
|
|
338
|
+
value = value.strip()
|
|
339
|
+
if value.startswith("[") and "]" in value:
|
|
340
|
+
closing_index = value.find("]")
|
|
341
|
+
value = value[1:closing_index]
|
|
342
|
+
if value.count(":") == 1 and re.search(r":\d+$", value):
|
|
343
|
+
value = value.rsplit(":", 1)[0]
|
|
344
|
+
if ":" in value and not value.startswith("["):
|
|
345
|
+
value = f"[{value}]"
|
|
346
|
+
return value
|
|
347
|
+
|
|
348
|
+
# Derive domain from homeserver (tolerate missing scheme; drop brackets/port/paths)
|
|
349
|
+
parsed = urlparse(homeserver)
|
|
350
|
+
domain = parsed.hostname or urlparse(f"//{homeserver}").hostname
|
|
351
|
+
if not domain:
|
|
352
|
+
# Last-ditch fallback for malformed inputs; drop any trailing :port
|
|
353
|
+
host = homeserver.split("://")[-1].split("/", 1)[0]
|
|
354
|
+
domain = re.sub(r":\d+$", "", host)
|
|
355
|
+
|
|
356
|
+
domain = _canonical_server(domain)
|
|
357
|
+
|
|
358
|
+
# Normalize user ID
|
|
359
|
+
localpart, *serverpart = bot_user_id.lstrip("@").split(":", 1)
|
|
360
|
+
if serverpart and serverpart[0]:
|
|
361
|
+
# Already has a server part; drop any brackets/port consistently
|
|
362
|
+
raw_server = serverpart[0]
|
|
363
|
+
server = urlparse(f"//{raw_server}").hostname or re.sub(
|
|
364
|
+
r":\d+$",
|
|
365
|
+
"",
|
|
366
|
+
raw_server,
|
|
367
|
+
)
|
|
368
|
+
server = _canonical_server(server)
|
|
369
|
+
return f"@{localpart}:{server}"
|
|
370
|
+
|
|
371
|
+
# No server part, add the derived domain
|
|
372
|
+
return f"@{localpart.rstrip(':')}:{domain}"
|
|
373
|
+
|
|
374
|
+
|
|
215
375
|
def _get_msgs_to_keep_config():
|
|
216
376
|
"""
|
|
217
377
|
Return the configured number of Meshtastic–Matrix message mappings to retain.
|
|
@@ -242,16 +402,112 @@ def _get_msgs_to_keep_config():
|
|
|
242
402
|
return msg_map_config.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
|
|
243
403
|
|
|
244
404
|
|
|
405
|
+
def _get_detailed_sync_error_message(sync_response) -> str:
|
|
406
|
+
"""
|
|
407
|
+
Return a concise, user-facing explanation for why an initial Matrix sync failed.
|
|
408
|
+
|
|
409
|
+
Given a sync response or error object (commonly a nio ErrorResponse, an HTTP/transport error, raw bytes, or any object exposing `message`, `status_code`, or `transport_response`), extract the most specific human-readable reason available and map common HTTP/transport conditions to short, actionable messages (e.g., authentication failure, forbidden, not found, rate limited, server error). Falls back to a generic network/connectivity message when no specific detail can be reliably extracted.
|
|
410
|
+
|
|
411
|
+
Parameters:
|
|
412
|
+
sync_response: The sync response or error object to summarize. May be bytes/bytearray, a nio ErrorResponse-like object, or any object with `message`, `status_code`, or `transport_response` attributes.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
str: A short, user-focused error description suitable for logs and brief troubleshooting hints.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
# Handle bytes/bytearray types by converting to string
|
|
419
|
+
if isinstance(sync_response, (bytes, bytearray)):
|
|
420
|
+
try:
|
|
421
|
+
sync_response = sync_response.decode("utf-8")
|
|
422
|
+
except UnicodeDecodeError:
|
|
423
|
+
return "Network connectivity issue or server unreachable (binary data)"
|
|
424
|
+
|
|
425
|
+
# Try to extract specific error information
|
|
426
|
+
if hasattr(sync_response, "message") and sync_response.message:
|
|
427
|
+
message = sync_response.message
|
|
428
|
+
# Handle if message is bytes/bytearray
|
|
429
|
+
if isinstance(message, (bytes, bytearray)):
|
|
430
|
+
try:
|
|
431
|
+
message = message.decode("utf-8")
|
|
432
|
+
except UnicodeDecodeError:
|
|
433
|
+
return "Network connectivity issue or server unreachable"
|
|
434
|
+
return message
|
|
435
|
+
elif hasattr(sync_response, "status_code") and sync_response.status_code:
|
|
436
|
+
status_code = sync_response.status_code
|
|
437
|
+
# Handle if status_code is not an int
|
|
438
|
+
try:
|
|
439
|
+
status_code = int(status_code)
|
|
440
|
+
except (ValueError, TypeError):
|
|
441
|
+
return "Network connectivity issue or server unreachable"
|
|
442
|
+
|
|
443
|
+
if status_code == 401:
|
|
444
|
+
return "Authentication failed - invalid or expired credentials"
|
|
445
|
+
elif status_code == 403:
|
|
446
|
+
return "Access forbidden - check user permissions"
|
|
447
|
+
elif status_code == 404:
|
|
448
|
+
return "Server not found - check homeserver URL"
|
|
449
|
+
elif status_code == 429:
|
|
450
|
+
return "Rate limited - too many requests"
|
|
451
|
+
elif status_code >= 500:
|
|
452
|
+
return f"Server error (HTTP {status_code}) - the Matrix server is experiencing issues"
|
|
453
|
+
else:
|
|
454
|
+
return f"HTTP error {status_code}"
|
|
455
|
+
elif hasattr(sync_response, "transport_response"):
|
|
456
|
+
# Check for transport-level errors
|
|
457
|
+
transport = sync_response.transport_response
|
|
458
|
+
if hasattr(transport, "status_code"):
|
|
459
|
+
try:
|
|
460
|
+
status_code = int(transport.status_code)
|
|
461
|
+
return f"Transport error: HTTP {status_code}"
|
|
462
|
+
except (ValueError, TypeError):
|
|
463
|
+
return "Network connectivity issue or server unreachable"
|
|
464
|
+
|
|
465
|
+
# Fallback to string representation with safety checks
|
|
466
|
+
try:
|
|
467
|
+
error_str = str(sync_response)
|
|
468
|
+
except Exception:
|
|
469
|
+
return "Network connectivity issue or server unreachable"
|
|
470
|
+
|
|
471
|
+
# Clean up object repr strings that contain angle brackets
|
|
472
|
+
if error_str and error_str != "None":
|
|
473
|
+
# Remove object repr patterns like <object at 0x...>
|
|
474
|
+
if "<" in error_str and ">" in error_str and " at 0x" in error_str:
|
|
475
|
+
return "Network connectivity issue or server unreachable"
|
|
476
|
+
# Remove HTML/XML-like content
|
|
477
|
+
elif "<" in error_str and ">" in error_str:
|
|
478
|
+
return "Network connectivity issue or server unreachable"
|
|
479
|
+
elif "unknown error" in error_str.lower():
|
|
480
|
+
return "Network connectivity issue or server unreachable"
|
|
481
|
+
else:
|
|
482
|
+
return error_str
|
|
483
|
+
else:
|
|
484
|
+
return "Network connectivity issue or server unreachable"
|
|
485
|
+
|
|
486
|
+
except (AttributeError, ValueError, TypeError) as e:
|
|
487
|
+
logger.debug(
|
|
488
|
+
"Failed to extract sync error details from %r: %s", sync_response, e
|
|
489
|
+
)
|
|
490
|
+
# If we can't extract error details, provide a generic but helpful message
|
|
491
|
+
return (
|
|
492
|
+
"Unable to determine specific error - likely a network connectivity issue"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
245
496
|
def _create_mapping_info(
|
|
246
497
|
matrix_event_id, room_id, text, meshnet=None, msgs_to_keep=None
|
|
247
498
|
):
|
|
248
499
|
"""
|
|
249
|
-
Create
|
|
500
|
+
Create metadata linking a Matrix event to a Meshtastic message for cross-network mapping.
|
|
250
501
|
|
|
251
|
-
|
|
502
|
+
Strips quoted lines from `text` and populates a mapping dict containing identifiers and retention settings. If `msgs_to_keep` is None the value is obtained from _get_msgs_to_keep_config(). Returns None when any of `matrix_event_id`, `room_id`, or `text` is missing or falsy.
|
|
252
503
|
|
|
253
504
|
Returns:
|
|
254
|
-
dict
|
|
505
|
+
dict or None: Mapping with keys:
|
|
506
|
+
- matrix_event_id: original Matrix event id
|
|
507
|
+
- room_id: Matrix room id
|
|
508
|
+
- text: cleaned text with quoted lines removed
|
|
509
|
+
- meshnet: optional meshnet name (may be None)
|
|
510
|
+
- msgs_to_keep: number of message mappings to retain
|
|
255
511
|
"""
|
|
256
512
|
if not matrix_event_id or not room_id or not text:
|
|
257
513
|
return None
|
|
@@ -533,26 +789,19 @@ def bot_command(command, event):
|
|
|
533
789
|
|
|
534
790
|
async def connect_matrix(passed_config=None):
|
|
535
791
|
"""
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
- Validate required configuration (including a required top-level "matrix_rooms" mapping).
|
|
541
|
-
- Create an AsyncClient with a certifi-backed SSL context.
|
|
542
|
-
- When E2EE is enabled and supported, prepare the encryption store, load keys, and upload device keys if needed.
|
|
543
|
-
- Perform an initial sync (full_state) to populate room state and then fetch the bot's display name.
|
|
544
|
-
- Return the initialized AsyncClient instance (and set several module-level globals used elsewhere).
|
|
545
|
-
|
|
792
|
+
Create and initialize a matrix-nio AsyncClient connected to the configured Matrix homeserver.
|
|
793
|
+
|
|
794
|
+
Attempts credential selection in this order: credentials.json (preferred), automatic login using username/password from config (saved to credentials.json), then direct token values from config. Optionally enables End-to-End Encryption (E2EE) when configured and dependencies are available. Performs an initial full-state sync, resolves configured room aliases to room IDs, sets module-level connection state (matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id) and returns the ready AsyncClient.
|
|
795
|
+
|
|
546
796
|
Parameters:
|
|
547
|
-
passed_config (dict | None): Optional configuration to use
|
|
548
|
-
|
|
797
|
+
passed_config (dict | None): Optional configuration to use for this connection attempt; if provided, it overrides the module-level config for this call.
|
|
798
|
+
|
|
549
799
|
Returns:
|
|
550
|
-
AsyncClient: An initialized matrix-nio AsyncClient
|
|
551
|
-
|
|
800
|
+
AsyncClient | None: An initialized matrix-nio AsyncClient on success, or None if connection/credentials are unavailable.
|
|
801
|
+
|
|
552
802
|
Raises:
|
|
553
|
-
ValueError: If the top-level "matrix_rooms" configuration is missing.
|
|
554
|
-
ConnectionError: If the initial sync
|
|
555
|
-
asyncio.TimeoutError: If the initial sync times out.
|
|
803
|
+
ValueError: If the required top-level "matrix_rooms" configuration is missing.
|
|
804
|
+
ConnectionError: If the initial Matrix sync fails or times out.
|
|
556
805
|
"""
|
|
557
806
|
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
558
807
|
|
|
@@ -581,7 +830,7 @@ async def connect_matrix(passed_config=None):
|
|
|
581
830
|
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
582
831
|
|
|
583
832
|
if os.path.exists(credentials_path):
|
|
584
|
-
with open(credentials_path, "r") as f:
|
|
833
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
585
834
|
credentials = json.load(f)
|
|
586
835
|
except Exception as e:
|
|
587
836
|
logger.warning(f"Error loading credentials: {e}")
|
|
@@ -619,6 +868,9 @@ async def connect_matrix(passed_config=None):
|
|
|
619
868
|
matrix_section = config["matrix"]
|
|
620
869
|
homeserver = matrix_section["homeserver"]
|
|
621
870
|
username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
|
|
871
|
+
# Normalize the username to ensure it's a full MXID
|
|
872
|
+
if username:
|
|
873
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
622
874
|
password = matrix_section["password"]
|
|
623
875
|
|
|
624
876
|
# Attempt automatic login
|
|
@@ -651,7 +903,7 @@ async def connect_matrix(passed_config=None):
|
|
|
651
903
|
)
|
|
652
904
|
return None
|
|
653
905
|
except Exception as e:
|
|
654
|
-
logger.
|
|
906
|
+
logger.exception(f"Error during automatic login: {type(e).__name__}")
|
|
655
907
|
logger.error("Please use 'mmrelay auth login' for interactive setup")
|
|
656
908
|
return None
|
|
657
909
|
else:
|
|
@@ -684,7 +936,9 @@ async def connect_matrix(passed_config=None):
|
|
|
684
936
|
# Extract Matrix configuration from config
|
|
685
937
|
matrix_homeserver = matrix_section["homeserver"]
|
|
686
938
|
matrix_access_token = matrix_section["access_token"]
|
|
687
|
-
bot_user_id =
|
|
939
|
+
bot_user_id = _normalize_bot_user_id(
|
|
940
|
+
matrix_homeserver, matrix_section["bot_user_id"]
|
|
941
|
+
)
|
|
688
942
|
|
|
689
943
|
# Manual method does not support device_id - use auth system for E2EE
|
|
690
944
|
e2ee_device_id = None
|
|
@@ -713,11 +967,18 @@ async def connect_matrix(passed_config=None):
|
|
|
713
967
|
e2ee_device_id = None
|
|
714
968
|
|
|
715
969
|
try:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
e2ee_enabled =
|
|
720
|
-
|
|
970
|
+
from mmrelay.config import is_e2ee_enabled
|
|
971
|
+
|
|
972
|
+
# Check if E2EE is enabled using the helper function
|
|
973
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
974
|
+
|
|
975
|
+
# Debug logging for E2EE detection
|
|
976
|
+
logger.debug(
|
|
977
|
+
f"E2EE detection: matrix config section present: {'matrix' in config}"
|
|
978
|
+
)
|
|
979
|
+
logger.debug(f"E2EE detection: e2ee enabled = {e2ee_enabled}")
|
|
980
|
+
|
|
981
|
+
if e2ee_enabled:
|
|
721
982
|
# Check if running on Windows
|
|
722
983
|
if sys.platform == WINDOWS_PLATFORM:
|
|
723
984
|
logger.error(
|
|
@@ -733,72 +994,90 @@ async def connect_matrix(passed_config=None):
|
|
|
733
994
|
else:
|
|
734
995
|
# Check if python-olm is installed
|
|
735
996
|
try:
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
# Also check for other required E2EE dependencies
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
):
|
|
768
|
-
e2ee_store_path = os.path.expanduser(
|
|
769
|
-
config["matrix"]["e2ee"]["store_path"]
|
|
770
|
-
)
|
|
997
|
+
importlib.import_module("olm")
|
|
998
|
+
|
|
999
|
+
# Also check for other required E2EE dependencies unless tests skip them
|
|
1000
|
+
if os.getenv("MMRELAY_TESTING") != "1":
|
|
1001
|
+
try:
|
|
1002
|
+
nio_crypto = importlib.import_module("nio.crypto")
|
|
1003
|
+
if not hasattr(nio_crypto, "OlmDevice"):
|
|
1004
|
+
raise ImportError("nio.crypto.OlmDevice is unavailable")
|
|
1005
|
+
|
|
1006
|
+
nio_store = importlib.import_module("nio.store")
|
|
1007
|
+
if not hasattr(nio_store, "SqliteStore"):
|
|
1008
|
+
raise ImportError(
|
|
1009
|
+
"nio.store.SqliteStore is unavailable"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
logger.debug("All E2EE dependencies are available")
|
|
1013
|
+
except ImportError:
|
|
1014
|
+
logger.exception("Missing E2EE dependency")
|
|
1015
|
+
logger.error(
|
|
1016
|
+
"Please reinstall with: pipx install 'mmrelay[e2e]'"
|
|
1017
|
+
)
|
|
1018
|
+
logger.warning("E2EE will be disabled for this session.")
|
|
1019
|
+
e2ee_enabled = False
|
|
1020
|
+
else:
|
|
1021
|
+
# Dependencies are available, keep the config-determined value
|
|
1022
|
+
if e2ee_enabled:
|
|
1023
|
+
logger.info("End-to-End Encryption (E2EE) is enabled")
|
|
1024
|
+
else:
|
|
1025
|
+
logger.debug(
|
|
1026
|
+
"E2EE dependencies available but E2EE is disabled in configuration"
|
|
1027
|
+
)
|
|
771
1028
|
else:
|
|
772
|
-
|
|
1029
|
+
logger.debug(
|
|
1030
|
+
"Skipping additional E2EE dependency imports in test mode"
|
|
1031
|
+
)
|
|
773
1032
|
|
|
774
|
-
|
|
1033
|
+
if e2ee_enabled:
|
|
1034
|
+
# Ensure nio still receives a store path even when dependency
|
|
1035
|
+
# checks are skipped (e.g. production runs without MMRELAY_TESTING);
|
|
1036
|
+
# without this the client will not load encryption state.
|
|
1037
|
+
# Get store path from config or use default
|
|
1038
|
+
if (
|
|
1039
|
+
"encryption" in config["matrix"]
|
|
1040
|
+
and "store_path" in config["matrix"]["encryption"]
|
|
1041
|
+
):
|
|
1042
|
+
e2ee_store_path = os.path.expanduser(
|
|
1043
|
+
config["matrix"]["encryption"]["store_path"]
|
|
1044
|
+
)
|
|
1045
|
+
elif (
|
|
1046
|
+
"e2ee" in config["matrix"]
|
|
1047
|
+
and "store_path" in config["matrix"]["e2ee"]
|
|
1048
|
+
):
|
|
1049
|
+
e2ee_store_path = os.path.expanduser(
|
|
1050
|
+
config["matrix"]["e2ee"]["store_path"]
|
|
1051
|
+
)
|
|
1052
|
+
else:
|
|
1053
|
+
e2ee_store_path = get_e2ee_store_dir()
|
|
775
1054
|
|
|
776
|
-
|
|
777
|
-
|
|
1055
|
+
# Create store directory if it doesn't exist
|
|
1056
|
+
os.makedirs(e2ee_store_path, exist_ok=True)
|
|
778
1057
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
)
|
|
785
|
-
db_files = [f for f in store_files if f.endswith(".db")]
|
|
786
|
-
if db_files:
|
|
787
|
-
logger.debug(
|
|
788
|
-
f"Found existing E2EE store files: {', '.join(db_files)}"
|
|
789
|
-
)
|
|
790
|
-
else:
|
|
791
|
-
logger.warning(
|
|
792
|
-
"No existing E2EE store files found. Encryption may not work correctly."
|
|
1058
|
+
# Check if store directory contains database files
|
|
1059
|
+
store_files = (
|
|
1060
|
+
os.listdir(e2ee_store_path)
|
|
1061
|
+
if os.path.exists(e2ee_store_path)
|
|
1062
|
+
else []
|
|
793
1063
|
)
|
|
1064
|
+
db_files = [f for f in store_files if f.endswith(".db")]
|
|
1065
|
+
if db_files:
|
|
1066
|
+
logger.debug(
|
|
1067
|
+
f"Found existing E2EE store files: {', '.join(db_files)}"
|
|
1068
|
+
)
|
|
1069
|
+
else:
|
|
1070
|
+
logger.warning(
|
|
1071
|
+
"No existing E2EE store files found. Encryption may not work correctly."
|
|
1072
|
+
)
|
|
794
1073
|
|
|
795
|
-
|
|
1074
|
+
logger.debug(f"Using E2EE store path: {e2ee_store_path}")
|
|
796
1075
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1076
|
+
# If device_id is not present in credentials, we can attempt to learn it later.
|
|
1077
|
+
if not e2ee_device_id:
|
|
1078
|
+
logger.debug(
|
|
1079
|
+
"No device_id in credentials; will retrieve from store/whoami later if available"
|
|
1080
|
+
)
|
|
802
1081
|
except ImportError:
|
|
803
1082
|
logger.warning(
|
|
804
1083
|
"E2EE is enabled in config but python-olm is not installed."
|
|
@@ -871,8 +1150,8 @@ async def connect_matrix(passed_config=None):
|
|
|
871
1150
|
logger.info("Encryption keys uploaded successfully")
|
|
872
1151
|
else:
|
|
873
1152
|
logger.debug("No key upload needed - keys already present")
|
|
874
|
-
except Exception
|
|
875
|
-
logger.
|
|
1153
|
+
except Exception:
|
|
1154
|
+
logger.exception("Failed to upload E2EE keys")
|
|
876
1155
|
# E2EE might still work, so we don't disable it here
|
|
877
1156
|
logger.error("Consider regenerating credentials with: mmrelay auth login")
|
|
878
1157
|
|
|
@@ -889,8 +1168,32 @@ async def connect_matrix(passed_config=None):
|
|
|
889
1168
|
hasattr(sync_response, "__class__")
|
|
890
1169
|
and "Error" in sync_response.__class__.__name__
|
|
891
1170
|
):
|
|
892
|
-
|
|
893
|
-
|
|
1171
|
+
# Provide more detailed error information
|
|
1172
|
+
error_type = sync_response.__class__.__name__
|
|
1173
|
+
error_details = _get_detailed_sync_error_message(sync_response)
|
|
1174
|
+
logger.error(f"Initial sync failed: {error_type}")
|
|
1175
|
+
logger.error(f"Error details: {error_details}")
|
|
1176
|
+
|
|
1177
|
+
# Provide user-friendly troubleshooting guidance
|
|
1178
|
+
if "SyncError" in error_type:
|
|
1179
|
+
logger.error(
|
|
1180
|
+
"This usually indicates a network connectivity issue or server problem."
|
|
1181
|
+
)
|
|
1182
|
+
logger.error("Troubleshooting steps:")
|
|
1183
|
+
logger.error("1. Check your internet connection")
|
|
1184
|
+
logger.error(
|
|
1185
|
+
f"2. Verify the homeserver URL is correct: {matrix_homeserver}"
|
|
1186
|
+
)
|
|
1187
|
+
logger.error("3. Ensure the Matrix server is online and accessible")
|
|
1188
|
+
logger.error("4. Check if your credentials are still valid")
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
await matrix_client.close()
|
|
1192
|
+
except Exception:
|
|
1193
|
+
logger.debug("Ignoring error while closing client after sync failure")
|
|
1194
|
+
finally:
|
|
1195
|
+
matrix_client = None
|
|
1196
|
+
raise ConnectionError(f"Matrix sync failed: {error_type} - {error_details}")
|
|
894
1197
|
else:
|
|
895
1198
|
logger.info(
|
|
896
1199
|
f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
|
|
@@ -906,6 +1209,35 @@ async def connect_matrix(passed_config=None):
|
|
|
906
1209
|
# Get comprehensive E2EE status
|
|
907
1210
|
e2ee_status = get_e2ee_status(config, config_path)
|
|
908
1211
|
|
|
1212
|
+
# Resolve room aliases in config (supports list[str|dict] and dict[str->str|dict])
|
|
1213
|
+
async def _resolve_alias(alias: str) -> Optional[str]:
|
|
1214
|
+
"""
|
|
1215
|
+
Resolve a Matrix room alias to its canonical room ID.
|
|
1216
|
+
|
|
1217
|
+
Attempts to resolve the provided room alias using the module's Matrix client. Returns the resolved room ID string on success; returns None if the alias cannot be resolved or if an error/timeout occurs (errors from the underlying nio client are caught and handled internally).
|
|
1218
|
+
"""
|
|
1219
|
+
logger.debug(f"Resolving alias from config: {alias}")
|
|
1220
|
+
try:
|
|
1221
|
+
response = await matrix_client.room_resolve_alias(alias)
|
|
1222
|
+
if hasattr(response, "room_id") and response.room_id:
|
|
1223
|
+
logger.debug(f"Resolved alias {alias} to {response.room_id}")
|
|
1224
|
+
return response.room_id
|
|
1225
|
+
logger.warning(
|
|
1226
|
+
f"Could not resolve alias {alias}: {getattr(response, 'message', response)}"
|
|
1227
|
+
)
|
|
1228
|
+
except (
|
|
1229
|
+
NioErrorResponse,
|
|
1230
|
+
NioLocalProtocolError,
|
|
1231
|
+
NioRemoteProtocolError,
|
|
1232
|
+
NioLocalTransportError,
|
|
1233
|
+
NioRemoteTransportError,
|
|
1234
|
+
asyncio.TimeoutError,
|
|
1235
|
+
):
|
|
1236
|
+
logger.exception(f"Error resolving alias {alias}")
|
|
1237
|
+
return None
|
|
1238
|
+
|
|
1239
|
+
await _resolve_aliases_in_mapping(matrix_rooms, _resolve_alias)
|
|
1240
|
+
|
|
909
1241
|
# Display rooms with channel mappings
|
|
910
1242
|
_display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
|
|
911
1243
|
|
|
@@ -929,10 +1261,30 @@ async def connect_matrix(passed_config=None):
|
|
|
929
1261
|
if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
|
|
930
1262
|
logger.debug("No encrypted rooms detected - all rooms are plaintext")
|
|
931
1263
|
except asyncio.TimeoutError:
|
|
932
|
-
logger.
|
|
1264
|
+
logger.exception(
|
|
933
1265
|
f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
|
|
934
1266
|
)
|
|
935
|
-
|
|
1267
|
+
logger.error(
|
|
1268
|
+
"This indicates a network connectivity issue or slow Matrix server."
|
|
1269
|
+
)
|
|
1270
|
+
logger.error("Troubleshooting steps:")
|
|
1271
|
+
logger.error("1. Check your internet connection")
|
|
1272
|
+
logger.error(f"2. Verify the homeserver is accessible: {matrix_homeserver}")
|
|
1273
|
+
logger.error(
|
|
1274
|
+
"3. Try again in a few minutes - the server may be temporarily overloaded"
|
|
1275
|
+
)
|
|
1276
|
+
logger.error(
|
|
1277
|
+
"4. Consider using a different Matrix homeserver if the problem persists"
|
|
1278
|
+
)
|
|
1279
|
+
try:
|
|
1280
|
+
await matrix_client.close()
|
|
1281
|
+
except Exception:
|
|
1282
|
+
logger.debug("Ignoring error while closing client after sync timeout")
|
|
1283
|
+
finally:
|
|
1284
|
+
matrix_client = None
|
|
1285
|
+
raise ConnectionError(
|
|
1286
|
+
f"Matrix sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds - check network connectivity and server status"
|
|
1287
|
+
) from None
|
|
936
1288
|
|
|
937
1289
|
# Add a delay to allow for key sharing to complete
|
|
938
1290
|
# This addresses a race condition where the client attempts to send encrypted messages
|
|
@@ -965,9 +1317,9 @@ async def login_matrix_bot(
|
|
|
965
1317
|
homeserver=None, username=None, password=None, logout_others=False
|
|
966
1318
|
):
|
|
967
1319
|
"""
|
|
968
|
-
Perform an interactive Matrix login for the bot
|
|
1320
|
+
Perform an interactive Matrix login for the bot and persist credentials for later use.
|
|
969
1321
|
|
|
970
|
-
This coroutine attempts server discovery for the provided homeserver, logs in as the given username, initializes an encrypted client store, and saves resulting credentials (homeserver, user_id, access_token, device_id) to credentials.json so the relay can restore the session non-interactively. If an existing credentials.json contains a matching user_id, the device_id will be reused when available.
|
|
1322
|
+
This coroutine attempts server discovery for the provided homeserver, logs in as the given username, optionally initializes an encrypted client store (if E2EE is enabled in configuration), and saves resulting credentials (homeserver, user_id, access_token, device_id) to credentials.json so the relay can restore the session non-interactively. If an existing credentials.json contains a matching user_id, the device_id will be reused when available.
|
|
971
1323
|
|
|
972
1324
|
Parameters:
|
|
973
1325
|
homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
|
|
@@ -979,11 +1331,13 @@ async def login_matrix_bot(
|
|
|
979
1331
|
bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
|
|
980
1332
|
"""
|
|
981
1333
|
try:
|
|
982
|
-
#
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1334
|
+
# Optionally enable verbose nio/aiohttp debug logging
|
|
1335
|
+
if os.getenv("MMRELAY_DEBUG_NIO") == "1":
|
|
1336
|
+
logging.getLogger("nio").setLevel(logging.DEBUG)
|
|
1337
|
+
logging.getLogger("nio.client").setLevel(logging.DEBUG)
|
|
1338
|
+
logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
|
|
1339
|
+
logging.getLogger("nio.responses").setLevel(logging.DEBUG)
|
|
1340
|
+
logging.getLogger("aiohttp").setLevel(logging.DEBUG)
|
|
987
1341
|
|
|
988
1342
|
# Get homeserver URL
|
|
989
1343
|
if not homeserver:
|
|
@@ -1004,6 +1358,10 @@ async def login_matrix_bot(
|
|
|
1004
1358
|
logger.warning(
|
|
1005
1359
|
"Failed to create SSL context for server discovery; falling back to default system SSL"
|
|
1006
1360
|
)
|
|
1361
|
+
else:
|
|
1362
|
+
logger.debug(f"SSL context created successfully: {ssl_context}")
|
|
1363
|
+
logger.debug(f"SSL context protocol: {ssl_context.protocol}")
|
|
1364
|
+
logger.debug(f"SSL context verify_mode: {ssl_context.verify_mode}")
|
|
1007
1365
|
|
|
1008
1366
|
# Create a temporary client for discovery
|
|
1009
1367
|
temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
|
|
@@ -1012,15 +1370,30 @@ async def login_matrix_bot(
|
|
|
1012
1370
|
temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
|
|
1013
1371
|
)
|
|
1014
1372
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1373
|
+
try:
|
|
1374
|
+
if isinstance(discovery_response, DiscoveryInfoResponse):
|
|
1375
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1376
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1377
|
+
homeserver = actual_homeserver
|
|
1378
|
+
elif isinstance(discovery_response, DiscoveryInfoError):
|
|
1379
|
+
logger.info(
|
|
1380
|
+
f"Server discovery failed, using original URL: {homeserver}"
|
|
1381
|
+
)
|
|
1382
|
+
# Continue with original homeserver URL
|
|
1383
|
+
else:
|
|
1384
|
+
# Fallback for test environments or unexpected response types
|
|
1385
|
+
if hasattr(discovery_response, "homeserver_url"):
|
|
1386
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1387
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1388
|
+
homeserver = actual_homeserver
|
|
1389
|
+
else:
|
|
1390
|
+
logger.warning(
|
|
1391
|
+
f"Server discovery returned unexpected response type, using original URL: {homeserver}"
|
|
1392
|
+
)
|
|
1393
|
+
except TypeError as e:
|
|
1394
|
+
logger.warning(
|
|
1395
|
+
f"Server discovery error: {e}, using original URL: {homeserver}"
|
|
1022
1396
|
)
|
|
1023
|
-
# Continue with original homeserver URL
|
|
1024
1397
|
|
|
1025
1398
|
except asyncio.TimeoutError:
|
|
1026
1399
|
logger.warning(
|
|
@@ -1040,19 +1413,37 @@ async def login_matrix_bot(
|
|
|
1040
1413
|
username = input("Enter Matrix username (without @): ")
|
|
1041
1414
|
|
|
1042
1415
|
# Format username correctly
|
|
1043
|
-
|
|
1044
|
-
username = f"@{username}"
|
|
1045
|
-
|
|
1046
|
-
server_name = urlparse(homeserver).netloc
|
|
1047
|
-
if ":" not in username:
|
|
1048
|
-
username = f"{username}:{server_name}"
|
|
1416
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
1049
1417
|
|
|
1050
1418
|
logger.info(f"Using username: {username}")
|
|
1051
1419
|
|
|
1420
|
+
# Validate username format
|
|
1421
|
+
if not username.startswith("@"):
|
|
1422
|
+
logger.warning(f"Username doesn't start with @: {username}")
|
|
1423
|
+
if username.count(":") != 1:
|
|
1424
|
+
logger.warning(
|
|
1425
|
+
f"Username has unexpected colon count: {username.count(':')}"
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
# Check for special characters in username that might cause issues
|
|
1429
|
+
username_special_chars = set(username) - set(
|
|
1430
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@:.-_"
|
|
1431
|
+
)
|
|
1432
|
+
if username_special_chars:
|
|
1433
|
+
logger.warning(
|
|
1434
|
+
f"Username contains unusual characters: {username_special_chars}"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1052
1437
|
# Get password
|
|
1053
1438
|
if not password:
|
|
1054
1439
|
password = getpass.getpass("Enter Matrix password: ")
|
|
1055
1440
|
|
|
1441
|
+
# Simple password validation without logging sensitive information
|
|
1442
|
+
if password:
|
|
1443
|
+
logger.debug("Password provided for login")
|
|
1444
|
+
else:
|
|
1445
|
+
logger.warning("No password provided")
|
|
1446
|
+
|
|
1056
1447
|
# Ask about logging out other sessions
|
|
1057
1448
|
if logout_others is None:
|
|
1058
1449
|
logout_others_input = input(
|
|
@@ -1065,11 +1456,13 @@ async def login_matrix_bot(
|
|
|
1065
1456
|
# Check for existing credentials to reuse device_id
|
|
1066
1457
|
existing_device_id = None
|
|
1067
1458
|
try:
|
|
1459
|
+
import json
|
|
1460
|
+
|
|
1068
1461
|
config_dir = get_base_dir()
|
|
1069
1462
|
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1070
1463
|
|
|
1071
1464
|
if os.path.exists(credentials_path):
|
|
1072
|
-
with open(credentials_path, "r") as f:
|
|
1465
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
1073
1466
|
existing_creds = json.load(f)
|
|
1074
1467
|
if (
|
|
1075
1468
|
"device_id" in existing_creds
|
|
@@ -1080,14 +1473,30 @@ async def login_matrix_bot(
|
|
|
1080
1473
|
except Exception as e:
|
|
1081
1474
|
logger.debug(f"Could not load existing credentials: {e}")
|
|
1082
1475
|
|
|
1083
|
-
#
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1476
|
+
# Check if E2EE is enabled in configuration
|
|
1477
|
+
from mmrelay.config import is_e2ee_enabled, load_config
|
|
1478
|
+
|
|
1479
|
+
try:
|
|
1480
|
+
config = load_config()
|
|
1481
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
1482
|
+
except Exception as e:
|
|
1483
|
+
logger.debug(f"Could not load config for E2EE check: {e}")
|
|
1484
|
+
e2ee_enabled = False
|
|
1485
|
+
|
|
1486
|
+
logger.debug(f"E2EE enabled in config: {e2ee_enabled}")
|
|
1087
1487
|
|
|
1088
|
-
#
|
|
1488
|
+
# Get the E2EE store path only if E2EE is enabled
|
|
1489
|
+
store_path = None
|
|
1490
|
+
if e2ee_enabled:
|
|
1491
|
+
store_path = get_e2ee_store_dir()
|
|
1492
|
+
os.makedirs(store_path, exist_ok=True)
|
|
1493
|
+
logger.debug(f"Using E2EE store path: {store_path}")
|
|
1494
|
+
else:
|
|
1495
|
+
logger.debug("E2EE disabled in configuration, not using store path")
|
|
1496
|
+
|
|
1497
|
+
# Create client config with E2EE based on configuration
|
|
1089
1498
|
client_config = AsyncClientConfig(
|
|
1090
|
-
store_sync_tokens=True, encryption_enabled=
|
|
1499
|
+
store_sync_tokens=True, encryption_enabled=e2ee_enabled
|
|
1091
1500
|
)
|
|
1092
1501
|
|
|
1093
1502
|
# Use the same SSL context as discovery client
|
|
@@ -1095,6 +1504,13 @@ async def login_matrix_bot(
|
|
|
1095
1504
|
|
|
1096
1505
|
# Initialize client with E2EE support
|
|
1097
1506
|
# Use most common pattern from matrix-nio examples: positional homeserver and user
|
|
1507
|
+
logger.debug("Creating AsyncClient with:")
|
|
1508
|
+
logger.debug(f" homeserver: {homeserver}")
|
|
1509
|
+
logger.debug(f" username: {username}")
|
|
1510
|
+
logger.debug(f" device_id: {existing_device_id}")
|
|
1511
|
+
logger.debug(f" store_path: {store_path}")
|
|
1512
|
+
logger.debug(f" e2ee_enabled: {e2ee_enabled}")
|
|
1513
|
+
|
|
1098
1514
|
client = AsyncClient(
|
|
1099
1515
|
homeserver,
|
|
1100
1516
|
username,
|
|
@@ -1104,45 +1520,183 @@ async def login_matrix_bot(
|
|
|
1104
1520
|
ssl=ssl_context,
|
|
1105
1521
|
)
|
|
1106
1522
|
|
|
1523
|
+
logger.debug("AsyncClient created successfully")
|
|
1524
|
+
|
|
1107
1525
|
logger.info(f"Logging in as {username} to {homeserver}...")
|
|
1108
1526
|
|
|
1109
1527
|
# Login with consistent device name and timeout
|
|
1110
|
-
# Use
|
|
1111
|
-
device_name = "mmrelay-e2ee"
|
|
1528
|
+
# Use appropriate device name based on E2EE configuration
|
|
1529
|
+
device_name = "mmrelay-e2ee" if e2ee_enabled else "mmrelay"
|
|
1112
1530
|
try:
|
|
1113
1531
|
# Set device_id on client if we have an existing one
|
|
1114
1532
|
if existing_device_id:
|
|
1115
1533
|
client.device_id = existing_device_id
|
|
1116
1534
|
|
|
1535
|
+
logger.debug(f"Attempting login to {homeserver} as {username}")
|
|
1536
|
+
logger.debug("Login parameters:")
|
|
1537
|
+
logger.debug(f" device_name: {device_name}")
|
|
1538
|
+
logger.debug(f" password length: {len(password) if password else 0}")
|
|
1539
|
+
logger.debug(f" client.user: {client.user}")
|
|
1540
|
+
logger.debug(f" client.homeserver: {client.homeserver}")
|
|
1541
|
+
|
|
1542
|
+
# Test the API call that matrix-nio will make
|
|
1543
|
+
try:
|
|
1544
|
+
from nio.api import Api
|
|
1545
|
+
|
|
1546
|
+
method, path, data = Api.login(
|
|
1547
|
+
user=username,
|
|
1548
|
+
password=password,
|
|
1549
|
+
device_name=device_name,
|
|
1550
|
+
device_id=existing_device_id,
|
|
1551
|
+
)
|
|
1552
|
+
logger.debug("Matrix API call details:")
|
|
1553
|
+
logger.debug(f" method: {method}")
|
|
1554
|
+
logger.debug(f" path: {path}")
|
|
1555
|
+
logger.debug(f" data length: {len(data) if data else 0}")
|
|
1556
|
+
|
|
1557
|
+
# Parse the JSON to see the structure (without logging the password)
|
|
1558
|
+
import json
|
|
1559
|
+
|
|
1560
|
+
parsed_data = json.loads(data)
|
|
1561
|
+
safe_data = {
|
|
1562
|
+
k: (v if k != "password" else f"[{len(v)} chars]")
|
|
1563
|
+
for k, v in parsed_data.items()
|
|
1564
|
+
}
|
|
1565
|
+
logger.debug(f" parsed data: {safe_data}")
|
|
1566
|
+
|
|
1567
|
+
except Exception as e:
|
|
1568
|
+
logger.error(f"Failed to test API call: {e}")
|
|
1569
|
+
|
|
1117
1570
|
response = await asyncio.wait_for(
|
|
1118
1571
|
client.login(password, device_name=device_name),
|
|
1119
1572
|
timeout=MATRIX_LOGIN_TIMEOUT,
|
|
1120
1573
|
)
|
|
1574
|
+
|
|
1575
|
+
# Debug: Log the response type and safe attributes only
|
|
1576
|
+
logger.debug(f"Login response type: {type(response).__name__}")
|
|
1577
|
+
|
|
1578
|
+
# Check specific attributes that should be present, masking sensitive data
|
|
1579
|
+
for attr in [
|
|
1580
|
+
"user_id",
|
|
1581
|
+
"device_id",
|
|
1582
|
+
"access_token",
|
|
1583
|
+
"status_code",
|
|
1584
|
+
"message",
|
|
1585
|
+
]:
|
|
1586
|
+
if hasattr(response, attr):
|
|
1587
|
+
value = getattr(response, attr)
|
|
1588
|
+
if attr == "access_token" and value:
|
|
1589
|
+
# Mask access token for security
|
|
1590
|
+
masked_value = (
|
|
1591
|
+
f"{value[:8]}...{value[-4:]}"
|
|
1592
|
+
if len(value) > 12
|
|
1593
|
+
else "***masked***"
|
|
1594
|
+
)
|
|
1595
|
+
logger.debug(
|
|
1596
|
+
f"Response.{attr}: {masked_value} (type: {type(value).__name__})"
|
|
1597
|
+
)
|
|
1598
|
+
else:
|
|
1599
|
+
logger.debug(
|
|
1600
|
+
f"Response.{attr}: {value} (type: {type(value).__name__})"
|
|
1601
|
+
)
|
|
1602
|
+
else:
|
|
1603
|
+
logger.debug(f"Response.{attr}: NOT PRESENT")
|
|
1121
1604
|
except asyncio.TimeoutError:
|
|
1122
|
-
logger.
|
|
1605
|
+
logger.exception(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
|
|
1123
1606
|
logger.error(
|
|
1124
1607
|
"This may indicate network connectivity issues or a slow Matrix server"
|
|
1125
1608
|
)
|
|
1126
1609
|
await client.close()
|
|
1127
1610
|
return False
|
|
1611
|
+
except TypeError as e:
|
|
1612
|
+
# Handle the specific ">=" comparison error that can occur in matrix-nio
|
|
1613
|
+
if "'>=' not supported between instances of 'str' and 'int'" in str(e):
|
|
1614
|
+
logger.error("Matrix-nio library error during login (known issue)")
|
|
1615
|
+
logger.error(
|
|
1616
|
+
"This typically indicates invalid credentials or server response format issues"
|
|
1617
|
+
)
|
|
1618
|
+
logger.error("Troubleshooting steps:")
|
|
1619
|
+
logger.error("1. Verify your username and password are correct")
|
|
1620
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1621
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1622
|
+
logger.error("4. Ensure your Matrix server supports the login API")
|
|
1623
|
+
logger.error(
|
|
1624
|
+
"5. Try using a different homeserver URL format (e.g., with https://)"
|
|
1625
|
+
)
|
|
1626
|
+
else:
|
|
1627
|
+
logger.exception("Type error during login")
|
|
1628
|
+
await client.close()
|
|
1629
|
+
return False
|
|
1128
1630
|
except Exception as e:
|
|
1129
1631
|
# Handle other exceptions during login (e.g., network errors)
|
|
1130
|
-
|
|
1131
|
-
logger.
|
|
1132
|
-
|
|
1133
|
-
|
|
1632
|
+
error_type = type(e).__name__
|
|
1633
|
+
logger.exception(f"Login failed with {error_type}")
|
|
1634
|
+
|
|
1635
|
+
# Provide specific guidance based on error type
|
|
1636
|
+
if isinstance(e, (ConnectionError, asyncio.TimeoutError)):
|
|
1637
|
+
logger.error("Network connectivity issue detected.")
|
|
1638
|
+
logger.error("Troubleshooting steps:")
|
|
1639
|
+
logger.error("1. Check your internet connection")
|
|
1640
|
+
logger.error(f"2. Verify the homeserver URL is correct: {homeserver}")
|
|
1641
|
+
logger.error("3. Check if the Matrix server is online")
|
|
1642
|
+
elif isinstance(e, (ssl.SSLError, ssl.CertificateError)):
|
|
1643
|
+
logger.error("SSL/TLS certificate issue detected.")
|
|
1644
|
+
logger.error(
|
|
1645
|
+
"This may indicate a problem with the server's SSL certificate."
|
|
1646
|
+
)
|
|
1647
|
+
elif "DNSError" in error_type or "NameResolutionError" in error_type:
|
|
1648
|
+
logger.error("DNS resolution failed.")
|
|
1649
|
+
logger.error(f"Cannot resolve hostname: {homeserver}")
|
|
1650
|
+
logger.error("Check your DNS settings and internet connection.")
|
|
1651
|
+
elif "'user_id' is a required property" in str(e):
|
|
1652
|
+
logger.error("Matrix server response validation failed.")
|
|
1653
|
+
logger.error("This typically indicates:")
|
|
1654
|
+
logger.error("1. Invalid username or password")
|
|
1655
|
+
logger.error("2. Server response format not as expected")
|
|
1656
|
+
logger.error("3. Matrix server compatibility issues")
|
|
1657
|
+
logger.error("Troubleshooting steps:")
|
|
1658
|
+
logger.error("1. Verify credentials by logging in via web browser")
|
|
1659
|
+
logger.error(
|
|
1660
|
+
"2. Try using the full homeserver URL (e.g., https://matrix.org)"
|
|
1661
|
+
)
|
|
1662
|
+
logger.error(
|
|
1663
|
+
"3. Check if your Matrix server is compatible with matrix-nio"
|
|
1664
|
+
)
|
|
1665
|
+
logger.error("4. Try a different Matrix server if available")
|
|
1666
|
+
|
|
1667
|
+
else:
|
|
1668
|
+
logger.error("Unexpected error during login.")
|
|
1669
|
+
|
|
1670
|
+
# Additional details already included in the message above.
|
|
1134
1671
|
await client.close()
|
|
1135
1672
|
return False
|
|
1136
1673
|
|
|
1137
|
-
|
|
1674
|
+
# Handle login response - check for access_token first (most reliable indicator)
|
|
1675
|
+
if hasattr(response, "access_token") and response.access_token:
|
|
1138
1676
|
logger.info("Login successful!")
|
|
1139
1677
|
|
|
1678
|
+
# Get the actual user_id from whoami() - this is the proper way
|
|
1679
|
+
try:
|
|
1680
|
+
whoami_response = await client.whoami()
|
|
1681
|
+
if hasattr(whoami_response, "user_id"):
|
|
1682
|
+
actual_user_id = whoami_response.user_id
|
|
1683
|
+
logger.debug(f"Got user_id from whoami: {actual_user_id}")
|
|
1684
|
+
else:
|
|
1685
|
+
# Fallback to response user_id or username
|
|
1686
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1687
|
+
logger.warning(
|
|
1688
|
+
f"whoami failed, using fallback user_id: {actual_user_id}"
|
|
1689
|
+
)
|
|
1690
|
+
except Exception as e:
|
|
1691
|
+
logger.warning(f"whoami call failed: {e}, using fallback")
|
|
1692
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1693
|
+
|
|
1140
1694
|
# Save credentials to credentials.json
|
|
1141
1695
|
credentials = {
|
|
1142
1696
|
"homeserver": homeserver,
|
|
1143
|
-
"user_id":
|
|
1697
|
+
"user_id": actual_user_id,
|
|
1144
1698
|
"access_token": response.access_token,
|
|
1145
|
-
"device_id": response
|
|
1699
|
+
"device_id": getattr(response, "device_id", existing_device_id),
|
|
1146
1700
|
}
|
|
1147
1701
|
|
|
1148
1702
|
config_dir = get_base_dir()
|
|
@@ -1159,66 +1713,171 @@ async def login_matrix_bot(
|
|
|
1159
1713
|
await client.close()
|
|
1160
1714
|
return True
|
|
1161
1715
|
else:
|
|
1162
|
-
#
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
logger.error(f"
|
|
1716
|
+
# Handle login failure
|
|
1717
|
+
if hasattr(response, "status_code") and hasattr(response, "message"):
|
|
1718
|
+
status_code = response.status_code
|
|
1719
|
+
error_message = response.message
|
|
1720
|
+
|
|
1721
|
+
logger.error(f"Login failed: {type(response).__name__}")
|
|
1722
|
+
logger.error(f"Error message: {error_message}")
|
|
1723
|
+
logger.error(f"HTTP status code: {status_code}")
|
|
1724
|
+
|
|
1725
|
+
# Provide specific troubleshooting guidance
|
|
1726
|
+
if status_code == 401 or "M_FORBIDDEN" in str(error_message):
|
|
1727
|
+
logger.error(
|
|
1728
|
+
"Authentication failed - invalid username or password."
|
|
1729
|
+
)
|
|
1730
|
+
logger.error("Troubleshooting steps:")
|
|
1731
|
+
logger.error("1. Verify your username and password are correct")
|
|
1732
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1733
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1734
|
+
logger.error(
|
|
1735
|
+
"4. Use 'mmrelay auth login' to set up new credentials"
|
|
1736
|
+
)
|
|
1737
|
+
elif status_code == 404:
|
|
1738
|
+
logger.error("User not found or homeserver not found.")
|
|
1739
|
+
logger.error(
|
|
1740
|
+
f"Check that the homeserver URL is correct: {homeserver}"
|
|
1741
|
+
)
|
|
1742
|
+
elif status_code == 429:
|
|
1743
|
+
logger.error("Rate limited - too many login attempts.")
|
|
1744
|
+
logger.error("Wait a few minutes before trying again.")
|
|
1745
|
+
elif status_code and int(status_code) >= 500:
|
|
1746
|
+
logger.error(
|
|
1747
|
+
"Matrix server error - the server is experiencing issues."
|
|
1748
|
+
)
|
|
1749
|
+
logger.error(
|
|
1750
|
+
"Try again later or contact your server administrator."
|
|
1751
|
+
)
|
|
1752
|
+
else:
|
|
1753
|
+
logger.error("Login failed for unknown reason.")
|
|
1754
|
+
logger.error(
|
|
1755
|
+
"Try using 'mmrelay auth login' for interactive setup."
|
|
1756
|
+
)
|
|
1757
|
+
else:
|
|
1758
|
+
logger.error(f"Unexpected login response: {type(response).__name__}")
|
|
1759
|
+
logger.error(
|
|
1760
|
+
"This may indicate a matrix-nio library issue or server problem."
|
|
1761
|
+
)
|
|
1762
|
+
|
|
1168
1763
|
await client.close()
|
|
1169
1764
|
return False
|
|
1170
1765
|
|
|
1171
|
-
except Exception
|
|
1172
|
-
logger.
|
|
1766
|
+
except Exception:
|
|
1767
|
+
logger.exception("Error during login")
|
|
1768
|
+
try:
|
|
1769
|
+
await client.close()
|
|
1770
|
+
except Exception as e:
|
|
1771
|
+
# Ignore errors during client cleanup - connection may already be closed
|
|
1772
|
+
logger.debug(f"Ignoring error during client cleanup: {e}")
|
|
1173
1773
|
return False
|
|
1174
1774
|
|
|
1175
1775
|
|
|
1176
1776
|
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
1177
1777
|
"""
|
|
1178
|
-
Join a Matrix room by ID or alias
|
|
1778
|
+
Join the bot to a Matrix room by ID or alias.
|
|
1179
1779
|
|
|
1180
|
-
|
|
1780
|
+
Resolves a room alias (e.g. "#room:server") to its canonical room ID, updates the in-memory
|
|
1781
|
+
matrix_rooms mapping with the resolved ID (if available), and attempts to join the resolved
|
|
1782
|
+
room ID. No-op if the client is already joined to the room. Errors during alias resolution
|
|
1783
|
+
or join are caught and logged; the function does not raise exceptions.
|
|
1181
1784
|
|
|
1182
|
-
Parameters:
|
|
1183
|
-
room_id_or_alias (str):
|
|
1785
|
+
Parameters documented only where meaning is not obvious:
|
|
1786
|
+
room_id_or_alias (str): A Matrix room identifier, either a canonical room ID (e.g. "!abc:server")
|
|
1787
|
+
or a room alias (starts with '#'). When an alias is provided, it will be resolved and
|
|
1788
|
+
the resolved room ID will be used for joining and recorded in the module's matrix_rooms mapping.
|
|
1789
|
+
|
|
1790
|
+
Returns:
|
|
1791
|
+
None
|
|
1184
1792
|
"""
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1793
|
+
|
|
1794
|
+
if not isinstance(room_id_or_alias, str):
|
|
1795
|
+
logger.error(
|
|
1796
|
+
"join_matrix_room expected a string room ID, received %r",
|
|
1797
|
+
room_id_or_alias,
|
|
1798
|
+
)
|
|
1799
|
+
return
|
|
1800
|
+
|
|
1801
|
+
room_id = room_id_or_alias
|
|
1802
|
+
|
|
1803
|
+
if room_id_or_alias.startswith("#"):
|
|
1804
|
+
try:
|
|
1188
1805
|
response = await matrix_client.room_resolve_alias(room_id_or_alias)
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1806
|
+
except (
|
|
1807
|
+
NioLocalProtocolError,
|
|
1808
|
+
NioRemoteProtocolError,
|
|
1809
|
+
NioErrorResponse,
|
|
1810
|
+
NioLocalTransportError,
|
|
1811
|
+
NioRemoteTransportError,
|
|
1812
|
+
asyncio.TimeoutError,
|
|
1813
|
+
):
|
|
1814
|
+
logger.exception("Error resolving alias '%s'", room_id_or_alias)
|
|
1815
|
+
return
|
|
1816
|
+
|
|
1817
|
+
room_id = getattr(response, "room_id", None) if response else None
|
|
1818
|
+
if not room_id:
|
|
1819
|
+
logger.error(
|
|
1820
|
+
"Failed to resolve alias '%s': %s",
|
|
1821
|
+
room_id_or_alias,
|
|
1822
|
+
getattr(response, "message", str(response)),
|
|
1823
|
+
)
|
|
1824
|
+
return
|
|
1825
|
+
|
|
1826
|
+
try:
|
|
1827
|
+
mapping = matrix_rooms
|
|
1828
|
+
except NameError:
|
|
1829
|
+
mapping = None
|
|
1830
|
+
|
|
1831
|
+
if mapping:
|
|
1832
|
+
try:
|
|
1833
|
+
_update_room_id_in_mapping(mapping, room_id_or_alias, room_id)
|
|
1834
|
+
except Exception:
|
|
1835
|
+
logger.debug(
|
|
1836
|
+
"Non-fatal error updating matrix_rooms for alias '%s'",
|
|
1837
|
+
room_id_or_alias,
|
|
1838
|
+
exc_info=True,
|
|
1192
1839
|
)
|
|
1193
|
-
return
|
|
1194
|
-
room_id = response.room_id
|
|
1195
|
-
# Update the room ID in the matrix_rooms list
|
|
1196
|
-
for room_config in matrix_rooms:
|
|
1197
|
-
if room_config["id"] == room_id_or_alias:
|
|
1198
|
-
room_config["id"] = room_id
|
|
1199
|
-
break
|
|
1200
|
-
else:
|
|
1201
|
-
room_id = room_id_or_alias
|
|
1202
1840
|
|
|
1203
|
-
|
|
1841
|
+
logger.info("Resolved alias '%s' -> '%s'", room_id_or_alias, room_id)
|
|
1842
|
+
|
|
1843
|
+
try:
|
|
1204
1844
|
if room_id not in matrix_client.rooms:
|
|
1205
1845
|
response = await matrix_client.join(room_id)
|
|
1206
|
-
|
|
1207
|
-
|
|
1846
|
+
joined_room_id = getattr(response, "room_id", None) if response else None
|
|
1847
|
+
if joined_room_id:
|
|
1848
|
+
logger.info(f"Joined room '{joined_room_id}' successfully")
|
|
1208
1849
|
else:
|
|
1209
1850
|
logger.error(
|
|
1210
|
-
|
|
1851
|
+
"Failed to join room '%s': %s",
|
|
1852
|
+
room_id,
|
|
1853
|
+
getattr(response, "message", str(response)),
|
|
1211
1854
|
)
|
|
1212
1855
|
else:
|
|
1213
|
-
logger.debug(
|
|
1214
|
-
|
|
1215
|
-
|
|
1856
|
+
logger.debug(
|
|
1857
|
+
"Bot is already in room '%s', no action needed.",
|
|
1858
|
+
room_id,
|
|
1859
|
+
)
|
|
1860
|
+
except (
|
|
1861
|
+
NioLocalProtocolError,
|
|
1862
|
+
NioRemoteProtocolError,
|
|
1863
|
+
NioErrorResponse,
|
|
1864
|
+
NioLocalTransportError,
|
|
1865
|
+
NioRemoteTransportError,
|
|
1866
|
+
asyncio.TimeoutError,
|
|
1867
|
+
):
|
|
1868
|
+
logger.exception(f"Error joining room '{room_id}'")
|
|
1216
1869
|
|
|
1217
1870
|
|
|
1218
1871
|
def _get_e2ee_error_message():
|
|
1219
1872
|
"""
|
|
1220
|
-
Return a
|
|
1221
|
-
|
|
1873
|
+
Return a user-facing string explaining why End-to-End Encryption (E2EE) is not enabled.
|
|
1874
|
+
|
|
1875
|
+
This queries the unified E2EE status (using the module-level config and config path)
|
|
1876
|
+
and converts that status into a concise error message suitable for logging or UI display.
|
|
1877
|
+
|
|
1878
|
+
Returns:
|
|
1879
|
+
str: A short, human-readable explanation of the current E2EE problem (empty or generic
|
|
1880
|
+
message if no specific issue is detected).
|
|
1222
1881
|
"""
|
|
1223
1882
|
from mmrelay.config import config_path
|
|
1224
1883
|
from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
|
|
@@ -1245,31 +1904,25 @@ async def matrix_relay(
|
|
|
1245
1904
|
reply_to_event_id=None,
|
|
1246
1905
|
):
|
|
1247
1906
|
"""
|
|
1248
|
-
Relay a Meshtastic message into a Matrix room
|
|
1907
|
+
Relay a Meshtastic message into a Matrix room and optionally record a Meshtastic↔Matrix mapping.
|
|
1249
1908
|
|
|
1250
|
-
|
|
1909
|
+
Formats the provided Meshtastic text into plain and HTML-safe formatted Matrix content (supports basic Markdown/HTML when libraries are available), applies Matrix reply framing when reply_to_event_id is provided, enforces E2EE restrictions (will not send into an encrypted room if the Matrix client's E2EE support is not enabled), sends the event via the global Matrix client, and—when message-interactions are enabled and a meshtastic_id is given—persists a mapping for later cross-network replies/reactions. Errors and timeouts are logged; the function does not raise on send/storage failures.
|
|
1251
1910
|
|
|
1252
|
-
Parameters:
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
meshtastic_id (str, optional): Meshtastic message ID; when provided and interactions/storage are enabled, a mapping from this Meshtastic ID to the resulting Matrix event will be persisted.
|
|
1260
|
-
meshtastic_replyId (str, optional): Meshtastic message ID being replied to; included as metadata on the Matrix event.
|
|
1261
|
-
meshtastic_text (str, optional): Original Meshtastic message text used when creating stored mappings.
|
|
1262
|
-
emote (bool, optional): If True, send as an m.emote (emote) message instead of regular text.
|
|
1263
|
-
emoji (bool, optional): If True, add emoji metadata to the Matrix event (used to mark emoji-like messages).
|
|
1264
|
-
reply_to_event_id (str, optional): Matrix event ID to which this message should be formatted as an m.in_reply_to reply.
|
|
1911
|
+
Parameters that require extra context:
|
|
1912
|
+
- meshtastic_id: when provided and storage is enabled, used to persist a mapping from the Meshtastic message to the created Matrix event for future reply/reaction handling.
|
|
1913
|
+
- meshtastic_replyId: original Meshtastic message ID being replied to; included as metadata on the Matrix event.
|
|
1914
|
+
- meshtastic_text: original Meshtastic text used when creating stored mappings (falls back to the relayed message if omitted).
|
|
1915
|
+
- emote: if True, the Matrix message is sent as an `m.emote` (emote) instead of `m.text`.
|
|
1916
|
+
- emoji: if True, a flag is added to the event to indicate emoji-like content.
|
|
1917
|
+
- reply_to_event_id: when provided, the outgoing Matrix event will include an `m.in_reply_to` relation and quoted/HTML reply content if the original mapping can be found.
|
|
1265
1918
|
|
|
1266
1919
|
Side effects:
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1920
|
+
- Sends a message to Matrix using the global Matrix client.
|
|
1921
|
+
- May persist a message mapping (Meshtastic ID → Matrix event) when configured.
|
|
1922
|
+
- Logs operational and error information; does not propagate send/storage exceptions to callers.
|
|
1270
1923
|
|
|
1271
1924
|
Returns:
|
|
1272
|
-
|
|
1925
|
+
- None
|
|
1273
1926
|
"""
|
|
1274
1927
|
global config
|
|
1275
1928
|
|
|
@@ -1314,33 +1967,37 @@ async def matrix_relay(
|
|
|
1314
1967
|
has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
|
|
1315
1968
|
has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
|
|
1316
1969
|
|
|
1317
|
-
# Process markdown
|
|
1970
|
+
# Process markdown/HTML if available; otherwise, safe fallback
|
|
1318
1971
|
if has_markdown or has_html:
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
raw_html
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1972
|
+
try:
|
|
1973
|
+
import bleach # lazy import
|
|
1974
|
+
import markdown # lazy import
|
|
1975
|
+
|
|
1976
|
+
raw_html = markdown.markdown(message)
|
|
1977
|
+
formatted_body = bleach.clean(
|
|
1978
|
+
raw_html,
|
|
1979
|
+
tags=[
|
|
1980
|
+
"b",
|
|
1981
|
+
"strong",
|
|
1982
|
+
"i",
|
|
1983
|
+
"em",
|
|
1984
|
+
"code",
|
|
1985
|
+
"pre",
|
|
1986
|
+
"br",
|
|
1987
|
+
"blockquote",
|
|
1988
|
+
"a",
|
|
1989
|
+
"ul",
|
|
1990
|
+
"ol",
|
|
1991
|
+
"li",
|
|
1992
|
+
"p",
|
|
1993
|
+
],
|
|
1994
|
+
attributes={"a": ["href"]},
|
|
1995
|
+
strip=True,
|
|
1996
|
+
)
|
|
1997
|
+
plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
|
|
1998
|
+
except ImportError:
|
|
1999
|
+
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
2000
|
+
plain_body = message
|
|
1344
2001
|
else:
|
|
1345
2002
|
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
1346
2003
|
plain_body = message
|
|
@@ -1388,17 +2045,17 @@ async def matrix_relay(
|
|
|
1388
2045
|
r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
|
|
1389
2046
|
)
|
|
1390
2047
|
quoted_text = (
|
|
1391
|
-
f">
|
|
2048
|
+
f"> <{bot_user_id}> [{safe_sender_display}]: {safe_original}"
|
|
1392
2049
|
)
|
|
1393
2050
|
content["body"] = f"{quoted_text}\n\n{plain_body}"
|
|
1394
2051
|
|
|
1395
2052
|
# Always use HTML formatting for replies since we need the mx-reply structure
|
|
1396
2053
|
content["format"] = "org.matrix.custom.html"
|
|
1397
2054
|
reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
|
|
1398
|
-
bot_link = f"https://matrix.to
|
|
2055
|
+
bot_link = f"https://matrix.to/#/{bot_user_id}"
|
|
1399
2056
|
blockquote_content = (
|
|
1400
2057
|
f'<a href="{reply_link}">In reply to</a> '
|
|
1401
|
-
f'<a href="{bot_link}"
|
|
2058
|
+
f'<a href="{bot_link}">{bot_user_id}</a><br>'
|
|
1402
2059
|
f"[{html.escape(original_sender_display)}]: {safe_original}"
|
|
1403
2060
|
)
|
|
1404
2061
|
content["formatted_body"] = (
|
|
@@ -1489,8 +2146,8 @@ async def matrix_relay(
|
|
|
1489
2146
|
except asyncio.TimeoutError:
|
|
1490
2147
|
logger.error(f"Timeout sending message to Matrix room {room_id}")
|
|
1491
2148
|
return
|
|
1492
|
-
except Exception
|
|
1493
|
-
logger.
|
|
2149
|
+
except Exception:
|
|
2150
|
+
logger.exception(f"Error sending message to Matrix room {room_id}")
|
|
1494
2151
|
return
|
|
1495
2152
|
|
|
1496
2153
|
# Only store message map if any interactions are enabled and conditions are met
|
|
@@ -1502,35 +2159,47 @@ async def matrix_relay(
|
|
|
1502
2159
|
and hasattr(response, "event_id")
|
|
1503
2160
|
):
|
|
1504
2161
|
try:
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2162
|
+
loop = asyncio.get_running_loop()
|
|
2163
|
+
# Store the message map in executor
|
|
2164
|
+
await loop.run_in_executor(
|
|
2165
|
+
None,
|
|
2166
|
+
lambda: store_message_map(
|
|
2167
|
+
meshtastic_id,
|
|
2168
|
+
response.event_id,
|
|
2169
|
+
room_id,
|
|
2170
|
+
meshtastic_text if meshtastic_text else message,
|
|
2171
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
2172
|
+
),
|
|
1512
2173
|
)
|
|
1513
2174
|
logger.debug(f"Stored message map for meshtastic_id: {meshtastic_id}")
|
|
1514
2175
|
|
|
1515
2176
|
# If msgs_to_keep > 0, prune old messages after inserting a new one
|
|
1516
2177
|
if msgs_to_keep > 0:
|
|
1517
|
-
prune_message_map
|
|
2178
|
+
await loop.run_in_executor(None, prune_message_map, msgs_to_keep)
|
|
1518
2179
|
except Exception as e:
|
|
1519
2180
|
logger.error(f"Error storing message map: {e}")
|
|
1520
2181
|
|
|
1521
2182
|
except asyncio.TimeoutError:
|
|
1522
2183
|
logger.error("Timed out while waiting for Matrix response")
|
|
1523
|
-
except Exception
|
|
1524
|
-
logger.
|
|
2184
|
+
except Exception:
|
|
2185
|
+
logger.exception(f"Error sending radio message to matrix room {room_id}")
|
|
1525
2186
|
|
|
1526
2187
|
|
|
1527
2188
|
def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
|
|
1528
2189
|
"""
|
|
1529
|
-
Truncate
|
|
2190
|
+
Truncate a string so its UTF-8 encoding fits within max_bytes.
|
|
2191
|
+
|
|
2192
|
+
Returns a substring whose UTF-8 byte length is at most `max_bytes`. If
|
|
2193
|
+
`max_bytes` falls in the middle of a multi-byte UTF-8 character, the
|
|
2194
|
+
incomplete character is dropped (decoding uses 'ignore').
|
|
1530
2195
|
|
|
1531
|
-
:
|
|
1532
|
-
|
|
1533
|
-
|
|
2196
|
+
Parameters:
|
|
2197
|
+
text (str): Input text to truncate.
|
|
2198
|
+
max_bytes (int): Maximum allowed size in bytes for the UTF-8 encoded result
|
|
2199
|
+
(defaults to DEFAULT_MESSAGE_TRUNCATE_BYTES).
|
|
2200
|
+
|
|
2201
|
+
Returns:
|
|
2202
|
+
str: Truncated string.
|
|
1534
2203
|
"""
|
|
1535
2204
|
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
|
|
1536
2205
|
return truncated_text
|
|
@@ -1543,16 +2212,21 @@ def strip_quoted_lines(text: str) -> str:
|
|
|
1543
2212
|
This is typically used to exclude quoted content from Matrix replies, such as when processing reaction text.
|
|
1544
2213
|
"""
|
|
1545
2214
|
lines = text.splitlines()
|
|
1546
|
-
filtered = [line for line in lines if not line.strip().startswith(">")]
|
|
1547
|
-
return " ".join(filtered).strip()
|
|
2215
|
+
filtered = [line.strip() for line in lines if not line.strip().startswith(">")]
|
|
2216
|
+
return " ".join(line for line in filtered if line).strip()
|
|
1548
2217
|
|
|
1549
2218
|
|
|
1550
2219
|
async def get_user_display_name(room, event):
|
|
1551
2220
|
"""
|
|
1552
|
-
|
|
2221
|
+
Return the display name for the event sender, preferring a room-specific name.
|
|
2222
|
+
|
|
2223
|
+
If the room provides a per-room display name for the sender, that name is returned.
|
|
2224
|
+
Otherwise the function performs an asynchronous lookup against the homeserver for the
|
|
2225
|
+
user's global display name and returns it if present. If no display name is available,
|
|
2226
|
+
the sender's Matrix ID (MXID) is returned.
|
|
1553
2227
|
|
|
1554
2228
|
Returns:
|
|
1555
|
-
str:
|
|
2229
|
+
str: A human-readable display name or the sender's MXID.
|
|
1556
2230
|
"""
|
|
1557
2231
|
room_display_name = room.user_name(event.sender)
|
|
1558
2232
|
if room_display_name:
|
|
@@ -1562,7 +2236,17 @@ async def get_user_display_name(room, event):
|
|
|
1562
2236
|
return display_name_response.displayname or event.sender
|
|
1563
2237
|
|
|
1564
2238
|
|
|
1565
|
-
def format_reply_message(
|
|
2239
|
+
def format_reply_message(
|
|
2240
|
+
config,
|
|
2241
|
+
full_display_name,
|
|
2242
|
+
text,
|
|
2243
|
+
*,
|
|
2244
|
+
longname=None,
|
|
2245
|
+
shortname=None,
|
|
2246
|
+
meshnet_name=None,
|
|
2247
|
+
local_meshnet_name=None,
|
|
2248
|
+
mesh_text_override=None,
|
|
2249
|
+
):
|
|
1566
2250
|
"""
|
|
1567
2251
|
Format a reply message by prefixing a truncated display name and removing quoted lines.
|
|
1568
2252
|
|
|
@@ -1575,11 +2259,50 @@ def format_reply_message(config, full_display_name, text):
|
|
|
1575
2259
|
Returns:
|
|
1576
2260
|
str: The formatted and truncated reply message.
|
|
1577
2261
|
"""
|
|
1578
|
-
|
|
2262
|
+
# Determine the base text to use (prefer the raw Meshtastic payload when present)
|
|
2263
|
+
base_text = mesh_text_override if mesh_text_override else text
|
|
2264
|
+
|
|
2265
|
+
clean_text = strip_quoted_lines(base_text).strip()
|
|
2266
|
+
|
|
2267
|
+
# Handle remote meshnet replies by using the remote sender's prefix format
|
|
2268
|
+
if meshnet_name and local_meshnet_name and meshnet_name != local_meshnet_name:
|
|
2269
|
+
sender_long = longname or full_display_name or shortname or "???"
|
|
2270
|
+
sender_short = shortname or sender_long[:SHORTNAME_FALLBACK_LENGTH] or "???"
|
|
2271
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
2272
|
+
|
|
2273
|
+
prefix_candidates = [
|
|
2274
|
+
f"[{sender_long}/{meshnet_name}]: ",
|
|
2275
|
+
f"[{sender_long}/{short_meshnet_name}]: ",
|
|
2276
|
+
f"{sender_long}/{meshnet_name}: ",
|
|
2277
|
+
f"{sender_long}/{short_meshnet_name}: ",
|
|
2278
|
+
f"{sender_short}/{meshnet_name}: ",
|
|
2279
|
+
f"{sender_short}/{short_meshnet_name}: ",
|
|
2280
|
+
]
|
|
1579
2281
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2282
|
+
matrix_prefix_full = get_matrix_prefix(
|
|
2283
|
+
config, sender_long, sender_short, meshnet_name
|
|
2284
|
+
)
|
|
2285
|
+
matrix_prefix_short = get_matrix_prefix(
|
|
2286
|
+
config, sender_long, sender_short, short_meshnet_name
|
|
2287
|
+
)
|
|
2288
|
+
prefix_candidates.extend([matrix_prefix_full, matrix_prefix_short])
|
|
2289
|
+
|
|
2290
|
+
for candidate in prefix_candidates:
|
|
2291
|
+
if candidate and clean_text.startswith(candidate):
|
|
2292
|
+
clean_text = clean_text[len(candidate) :].lstrip()
|
|
2293
|
+
break
|
|
2294
|
+
|
|
2295
|
+
if not clean_text and mesh_text_override:
|
|
2296
|
+
clean_text = strip_quoted_lines(mesh_text_override).strip()
|
|
2297
|
+
|
|
2298
|
+
mesh_prefix = f"{sender_short}/{short_meshnet_name}:"
|
|
2299
|
+
reply_body = f" {clean_text}" if clean_text else ""
|
|
2300
|
+
reply_message = f"{mesh_prefix}{reply_body}"
|
|
2301
|
+
return truncate_message(reply_message.strip())
|
|
2302
|
+
|
|
2303
|
+
# Default behavior for local Matrix users (retain existing prefix logic)
|
|
2304
|
+
prefix = get_meshtastic_prefix(config, full_display_name)
|
|
2305
|
+
reply_message = f"{prefix}{clean_text}" if clean_text else prefix.rstrip()
|
|
1583
2306
|
return truncate_message(reply_message)
|
|
1584
2307
|
|
|
1585
2308
|
|
|
@@ -1595,29 +2318,20 @@ async def send_reply_to_meshtastic(
|
|
|
1595
2318
|
reply_id=None,
|
|
1596
2319
|
):
|
|
1597
2320
|
"""
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
If Meshtastic broadcasting is disabled
|
|
1601
|
-
|
|
2321
|
+
Enqueue a Matrix reply to be sent over Meshtastic, either as a structured reply targeting an existing Meshtastic message or as a regular broadcast.
|
|
2322
|
+
|
|
2323
|
+
If Meshtastic broadcasting is disabled this is a no-op. When storage_enabled is True the function will create a mapping entry (using event.event_id and room.room_id) and attach it to the queued message for later reply/reaction correlation. Failures are logged; the function does not raise exceptions.
|
|
2324
|
+
|
|
1602
2325
|
Parameters:
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
storage_enabled (bool): If True, attach mapping metadata to the queued Meshtastic message.
|
|
1610
|
-
local_meshnet_name (str | None): Optional meshnet identifier to include in mapping metadata.
|
|
1611
|
-
reply_id (int | None): Meshtastic message ID to target for a structured reply; if None, a regular broadcast is sent.
|
|
1612
|
-
|
|
1613
|
-
Returns:
|
|
1614
|
-
None
|
|
1615
|
-
|
|
1616
|
-
Notes:
|
|
1617
|
-
- The function logs errors and does not raise; actual transmission is handled asynchronously by the Meshtastic queue system.
|
|
1618
|
-
- Mapping creation uses configured limits (msgs_to_keep) and _create_mapping_info; if mapping creation fails, the message is still attempted without mapping.
|
|
2326
|
+
room_config (dict): Room-specific configuration — must include "meshtastic_channel" (integer channel index).
|
|
2327
|
+
room: Matrix room object; room.room_id is used to build mapping metadata.
|
|
2328
|
+
event: Matrix event object; event.event_id is used to build mapping metadata.
|
|
2329
|
+
storage_enabled (bool): If True, create and attach a message-mapping record to the queued Meshtastic message.
|
|
2330
|
+
reply_id (int | None): If provided, send as a structured Meshtastic reply targeting this message ID; otherwise send a regular text broadcast.
|
|
2331
|
+
local_meshnet_name (str | None): Name of the local meshnet used in mapping metadata.
|
|
1619
2332
|
"""
|
|
1620
|
-
|
|
2333
|
+
loop = asyncio.get_running_loop()
|
|
2334
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
1621
2335
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
1622
2336
|
|
|
1623
2337
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
@@ -1699,8 +2413,8 @@ async def send_reply_to_meshtastic(
|
|
|
1699
2413
|
|
|
1700
2414
|
# Message mapping is now handled automatically by the queue system
|
|
1701
2415
|
|
|
1702
|
-
except Exception
|
|
1703
|
-
meshtastic_logger.
|
|
2416
|
+
except Exception:
|
|
2417
|
+
meshtastic_logger.exception("Error sending Matrix reply to Meshtastic")
|
|
1704
2418
|
|
|
1705
2419
|
|
|
1706
2420
|
async def handle_matrix_reply(
|
|
@@ -1712,17 +2426,35 @@ async def handle_matrix_reply(
|
|
|
1712
2426
|
storage_enabled,
|
|
1713
2427
|
local_meshnet_name,
|
|
1714
2428
|
config,
|
|
2429
|
+
*,
|
|
2430
|
+
mesh_text_override=None,
|
|
2431
|
+
longname=None,
|
|
2432
|
+
shortname=None,
|
|
2433
|
+
meshnet_name=None,
|
|
1715
2434
|
):
|
|
1716
2435
|
"""
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
2436
|
+
Handle a Matrix reply by forwarding it to Meshtastic when the replied-to Matrix event maps to a Meshtastic message.
|
|
2437
|
+
|
|
2438
|
+
If the Matrix event identified by reply_to_event_id has an associated Meshtastic mapping, this function formats a Meshtastic reply that preserves sender attribution and enqueues it referencing the original Meshtastic message ID. If no mapping exists, it returns False so normal Matrix processing can continue.
|
|
2439
|
+
|
|
2440
|
+
Parameters:
|
|
2441
|
+
reply_to_event_id (str): Matrix event ID being replied to; used to locate the Meshtastic mapping.
|
|
2442
|
+
storage_enabled (bool): Whether message mappings/storage are enabled (affects created mapping behavior when sending).
|
|
2443
|
+
local_meshnet_name (str): Local meshnet name used to determine formatting when replying across meshnets.
|
|
2444
|
+
config (dict): Relay configuration passed to formatting routines.
|
|
2445
|
+
mesh_text_override (str | None): Optional text to send instead of derived text.
|
|
2446
|
+
longname (str | None): Sender long display name used for prefixing.
|
|
2447
|
+
shortname (str | None): Sender short display name used for prefixing.
|
|
2448
|
+
meshnet_name (str | None): Remote meshnet name associated with the original mapping, if any.
|
|
2449
|
+
|
|
1721
2450
|
Returns:
|
|
1722
|
-
bool: True if the reply was
|
|
2451
|
+
bool: True if a mapping was found and the reply was queued to Meshtastic; False if no mapping existed and nothing was sent.
|
|
1723
2452
|
"""
|
|
1724
2453
|
# Look up the original message in the message map
|
|
1725
|
-
|
|
2454
|
+
loop = asyncio.get_running_loop()
|
|
2455
|
+
orig = await loop.run_in_executor(
|
|
2456
|
+
None, get_message_map_by_matrix_event_id, reply_to_event_id
|
|
2457
|
+
)
|
|
1726
2458
|
if not orig:
|
|
1727
2459
|
logger.debug(
|
|
1728
2460
|
f"Original message for Matrix reply not found in DB: {reply_to_event_id}"
|
|
@@ -1737,7 +2469,16 @@ async def handle_matrix_reply(
|
|
|
1737
2469
|
full_display_name = await get_user_display_name(room, event)
|
|
1738
2470
|
|
|
1739
2471
|
# Format the reply message
|
|
1740
|
-
reply_message = format_reply_message(
|
|
2472
|
+
reply_message = format_reply_message(
|
|
2473
|
+
config,
|
|
2474
|
+
full_display_name,
|
|
2475
|
+
text,
|
|
2476
|
+
longname=longname,
|
|
2477
|
+
shortname=shortname,
|
|
2478
|
+
meshnet_name=meshnet_name,
|
|
2479
|
+
local_meshnet_name=local_meshnet_name,
|
|
2480
|
+
mesh_text_override=mesh_text_override,
|
|
2481
|
+
)
|
|
1741
2482
|
|
|
1742
2483
|
logger.info(
|
|
1743
2484
|
f"Relaying Matrix reply from {full_display_name} to Meshtastic as reply to message {original_meshtastic_id}"
|
|
@@ -1789,8 +2530,8 @@ async def on_decryption_failure(room: MatrixRoom, event: MegolmEvent) -> None:
|
|
|
1789
2530
|
request = event.as_key_request(matrix_client.user_id, matrix_client.device_id)
|
|
1790
2531
|
await matrix_client.to_device(request)
|
|
1791
2532
|
logger.info(f"Requested keys for failed decryption of event {event.event_id}")
|
|
1792
|
-
except Exception
|
|
1793
|
-
logger.
|
|
2533
|
+
except Exception:
|
|
2534
|
+
logger.exception(f"Failed to request keys for event {event.event_id}")
|
|
1794
2535
|
|
|
1795
2536
|
|
|
1796
2537
|
# Callback for new messages in Matrix room
|
|
@@ -1804,22 +2545,20 @@ async def on_room_message(
|
|
|
1804
2545
|
],
|
|
1805
2546
|
) -> None:
|
|
1806
2547
|
"""
|
|
1807
|
-
Handle an incoming Matrix room event and relay
|
|
2548
|
+
Handle an incoming Matrix room event and, when applicable, relay it to Meshtastic.
|
|
1808
2549
|
|
|
1809
|
-
Processes
|
|
2550
|
+
Processes text, notice, emote, and reaction events (including replies and messages relayed from other meshnets) for configured rooms. Behavior highlights:
|
|
1810
2551
|
- Ignores events from before the bot started and events sent by the bot itself.
|
|
1811
|
-
-
|
|
1812
|
-
-
|
|
1813
|
-
-
|
|
1814
|
-
-
|
|
1815
|
-
-
|
|
1816
|
-
- For regular messages: applies configured prefix formatting, truncation, and special handling for messages originating from remote meshnets; supports detection-sensor forwarding when the port indicates detection data.
|
|
1817
|
-
- Integrates with the plugin system: plugins can handle or consume messages/commands; messages identified as commands directed at the bot are not relayed to Meshtastic.
|
|
2552
|
+
- Uses per-room configuration and global interaction settings to decide whether to process or ignore the event.
|
|
2553
|
+
- Routes reactions back to the originating Meshtastic message when a mapping exists (supports local and remote-meshnet reaction handling).
|
|
2554
|
+
- Bridges Matrix replies to Meshtastic replies when a corresponding Meshtastic mapping is found and replies are enabled.
|
|
2555
|
+
- Relays regular Matrix messages to Meshtastic using configured prefix/truncation rules; handles special detection-sensor port forwarding.
|
|
2556
|
+
- Integrates with the plugin system; plugins may consume or modify messages. Messages identified as bot commands are not relayed to Meshtastic.
|
|
1818
2557
|
|
|
1819
2558
|
Side effects:
|
|
1820
|
-
- May enqueue
|
|
1821
|
-
- May read
|
|
1822
|
-
- May call Matrix APIs to fetch display names.
|
|
2559
|
+
- May enqueue Meshtastic send operations (text or data) via the internal queue.
|
|
2560
|
+
- May read/write persistent message mappings to support reaction/reply bridging.
|
|
2561
|
+
- May call Matrix APIs (e.g., to fetch display names).
|
|
1823
2562
|
|
|
1824
2563
|
Returns:
|
|
1825
2564
|
- None
|
|
@@ -1854,8 +2593,9 @@ async def on_room_message(
|
|
|
1854
2593
|
|
|
1855
2594
|
# Find the room_config that matches this room, if any
|
|
1856
2595
|
room_config = None
|
|
1857
|
-
|
|
1858
|
-
|
|
2596
|
+
iterable = matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
|
|
2597
|
+
for room_conf in iterable:
|
|
2598
|
+
if isinstance(room_conf, dict) and room_conf.get("id") == room.room_id:
|
|
1859
2599
|
room_config = room_conf
|
|
1860
2600
|
break
|
|
1861
2601
|
|
|
@@ -1908,6 +2648,17 @@ async def on_room_message(
|
|
|
1908
2648
|
|
|
1909
2649
|
text = event.body.strip() if (not is_reaction and hasattr(event, "body")) else ""
|
|
1910
2650
|
|
|
2651
|
+
# Some Matrix relays (especially Meshtastic bridges) provide the raw mesh
|
|
2652
|
+
# payload alongside the formatted body. Prefer that when available so we do
|
|
2653
|
+
# not lose content if the formatted text is empty or stripped unexpectedly.
|
|
2654
|
+
mesh_text_override = event.source["content"].get("meshtastic_text")
|
|
2655
|
+
if isinstance(mesh_text_override, str):
|
|
2656
|
+
mesh_text_override = mesh_text_override.strip()
|
|
2657
|
+
if not mesh_text_override:
|
|
2658
|
+
mesh_text_override = None
|
|
2659
|
+
else:
|
|
2660
|
+
mesh_text_override = None
|
|
2661
|
+
|
|
1911
2662
|
longname = event.source["content"].get("meshtastic_longname")
|
|
1912
2663
|
shortname = event.source["content"].get("meshtastic_shortname", None)
|
|
1913
2664
|
meshnet_name = event.source["content"].get("meshtastic_meshnet")
|
|
@@ -1972,7 +2723,13 @@ async def on_room_message(
|
|
|
1972
2723
|
reaction_message = f'{shortname}/{short_meshnet_name} reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
1973
2724
|
|
|
1974
2725
|
# Relay the remote reaction to the local meshnet.
|
|
1975
|
-
|
|
2726
|
+
loop = asyncio.get_running_loop()
|
|
2727
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2728
|
+
if not meshtastic_interface:
|
|
2729
|
+
logger.error(
|
|
2730
|
+
"Failed to connect to Meshtastic for remote reaction relay"
|
|
2731
|
+
)
|
|
2732
|
+
return
|
|
1976
2733
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
1977
2734
|
|
|
1978
2735
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
@@ -2047,7 +2804,11 @@ async def on_room_message(
|
|
|
2047
2804
|
reaction_message = (
|
|
2048
2805
|
f'{prefix}reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
2049
2806
|
)
|
|
2050
|
-
|
|
2807
|
+
loop = asyncio.get_running_loop()
|
|
2808
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2809
|
+
if not meshtastic_interface:
|
|
2810
|
+
logger.error("Failed to connect to Meshtastic for local reaction relay")
|
|
2811
|
+
return
|
|
2051
2812
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2052
2813
|
|
|
2053
2814
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
@@ -2088,6 +2849,10 @@ async def on_room_message(
|
|
|
2088
2849
|
storage_enabled,
|
|
2089
2850
|
local_meshnet_name,
|
|
2090
2851
|
config,
|
|
2852
|
+
mesh_text_override=mesh_text_override,
|
|
2853
|
+
longname=longname,
|
|
2854
|
+
shortname=shortname,
|
|
2855
|
+
meshnet_name=meshnet_name,
|
|
2091
2856
|
)
|
|
2092
2857
|
if reply_handled:
|
|
2093
2858
|
return
|
|
@@ -2104,6 +2869,8 @@ async def on_room_message(
|
|
|
2104
2869
|
# If shortname is not available, derive it from the longname
|
|
2105
2870
|
if shortname is None:
|
|
2106
2871
|
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
2872
|
+
if mesh_text_override:
|
|
2873
|
+
text = mesh_text_override
|
|
2107
2874
|
# Remove the original prefix to avoid double-tagging
|
|
2108
2875
|
# Get the prefix that would have been used for this message
|
|
2109
2876
|
original_prefix = get_matrix_prefix(
|
|
@@ -2114,10 +2881,18 @@ async def on_room_message(
|
|
|
2114
2881
|
logger.debug(
|
|
2115
2882
|
f"Removed original prefix '{original_prefix}' from remote meshnet message"
|
|
2116
2883
|
)
|
|
2884
|
+
if not text and mesh_text_override:
|
|
2885
|
+
text = mesh_text_override
|
|
2117
2886
|
text = truncate_message(text)
|
|
2118
2887
|
# Use the configured prefix format for remote meshnet messages
|
|
2119
2888
|
prefix = get_matrix_prefix(config, longname, shortname, short_meshnet_name)
|
|
2120
2889
|
full_message = f"{prefix}{text}"
|
|
2890
|
+
if not text:
|
|
2891
|
+
logger.warning(
|
|
2892
|
+
"Remote meshnet message from %s had empty text after formatting; skipping relay",
|
|
2893
|
+
meshnet_name,
|
|
2894
|
+
)
|
|
2895
|
+
return
|
|
2121
2896
|
else:
|
|
2122
2897
|
# If this message is from our local meshnet (loopback), we ignore it
|
|
2123
2898
|
return
|
|
@@ -2173,7 +2948,8 @@ async def on_room_message(
|
|
|
2173
2948
|
return
|
|
2174
2949
|
|
|
2175
2950
|
# Connect to Meshtastic
|
|
2176
|
-
|
|
2951
|
+
loop = asyncio.get_running_loop()
|
|
2952
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2177
2953
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2178
2954
|
|
|
2179
2955
|
if not meshtastic_interface:
|