mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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 +735 -135
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +198 -71
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +6 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1069 -293
- mmrelay/meshtastic_utils.py +350 -206
- mmrelay/message_queue.py +212 -62
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +43 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +324 -129
- 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 +14 -13
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/METADATA +11 -11
- mmrelay-1.2.2.dist-info/RECORD +48 -0
- mmrelay-1.2.0.dist-info/RECORD +0 -45
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.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,30 @@ def bot_command(command, event):
|
|
|
533
789
|
|
|
534
790
|
async def connect_matrix(passed_config=None):
|
|
535
791
|
"""
|
|
536
|
-
|
|
792
|
+
Create and initialize a matrix-nio AsyncClient connected to the configured Matrix homeserver, optionally enabling End-to-End Encryption (E2EE).
|
|
793
|
+
|
|
794
|
+
This routine selects credentials in the following order:
|
|
795
|
+
1. credentials.json (preferred; restores full session including device_id and E2EE store).
|
|
796
|
+
2. Automatic login using username/password from the provided config (saved to credentials.json on success).
|
|
797
|
+
3. Direct token-based values from the config matrix section.
|
|
537
798
|
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
-
|
|
541
|
-
-
|
|
542
|
-
-
|
|
543
|
-
-
|
|
544
|
-
-
|
|
799
|
+
Behavior summary:
|
|
800
|
+
- Validates presence of a top-level "matrix_rooms" configuration and raises ValueError if missing.
|
|
801
|
+
- Builds an AsyncClient with a certifi-backed SSL context when available.
|
|
802
|
+
- When E2EE is enabled in configuration and dependencies are present, prepares the encryption store, restores session state, and uploads device keys if needed.
|
|
803
|
+
- Performs an initial full_state sync and resolves room aliases configured as aliases to room IDs.
|
|
804
|
+
- Populates module-level globals used elsewhere (e.g., matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id).
|
|
805
|
+
- Returns the initialized AsyncClient instance ready for use.
|
|
545
806
|
|
|
546
807
|
Parameters:
|
|
547
|
-
passed_config (dict | None): Optional configuration to use
|
|
808
|
+
passed_config (dict | None): Optional configuration to use for this connection attempt. If provided, it replaces the module-level config for this call.
|
|
548
809
|
|
|
549
810
|
Returns:
|
|
550
|
-
AsyncClient: An initialized matrix-nio AsyncClient
|
|
811
|
+
AsyncClient | None: An initialized matrix-nio AsyncClient on success, or None if connection cannot be established due to missing configuration/credentials.
|
|
551
812
|
|
|
552
813
|
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.
|
|
814
|
+
ValueError: If the top-level "matrix_rooms" configuration is missing from the (effective) config.
|
|
815
|
+
ConnectionError: If the initial Matrix sync fails or times out.
|
|
556
816
|
"""
|
|
557
817
|
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
558
818
|
|
|
@@ -581,7 +841,7 @@ async def connect_matrix(passed_config=None):
|
|
|
581
841
|
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
582
842
|
|
|
583
843
|
if os.path.exists(credentials_path):
|
|
584
|
-
with open(credentials_path, "r") as f:
|
|
844
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
585
845
|
credentials = json.load(f)
|
|
586
846
|
except Exception as e:
|
|
587
847
|
logger.warning(f"Error loading credentials: {e}")
|
|
@@ -619,6 +879,9 @@ async def connect_matrix(passed_config=None):
|
|
|
619
879
|
matrix_section = config["matrix"]
|
|
620
880
|
homeserver = matrix_section["homeserver"]
|
|
621
881
|
username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
|
|
882
|
+
# Normalize the username to ensure it's a full MXID
|
|
883
|
+
if username:
|
|
884
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
622
885
|
password = matrix_section["password"]
|
|
623
886
|
|
|
624
887
|
# Attempt automatic login
|
|
@@ -651,7 +914,7 @@ async def connect_matrix(passed_config=None):
|
|
|
651
914
|
)
|
|
652
915
|
return None
|
|
653
916
|
except Exception as e:
|
|
654
|
-
logger.
|
|
917
|
+
logger.exception(f"Error during automatic login: {type(e).__name__}")
|
|
655
918
|
logger.error("Please use 'mmrelay auth login' for interactive setup")
|
|
656
919
|
return None
|
|
657
920
|
else:
|
|
@@ -684,7 +947,9 @@ async def connect_matrix(passed_config=None):
|
|
|
684
947
|
# Extract Matrix configuration from config
|
|
685
948
|
matrix_homeserver = matrix_section["homeserver"]
|
|
686
949
|
matrix_access_token = matrix_section["access_token"]
|
|
687
|
-
bot_user_id =
|
|
950
|
+
bot_user_id = _normalize_bot_user_id(
|
|
951
|
+
matrix_homeserver, matrix_section["bot_user_id"]
|
|
952
|
+
)
|
|
688
953
|
|
|
689
954
|
# Manual method does not support device_id - use auth system for E2EE
|
|
690
955
|
e2ee_device_id = None
|
|
@@ -713,11 +978,18 @@ async def connect_matrix(passed_config=None):
|
|
|
713
978
|
e2ee_device_id = None
|
|
714
979
|
|
|
715
980
|
try:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
e2ee_enabled =
|
|
720
|
-
|
|
981
|
+
from mmrelay.config import is_e2ee_enabled
|
|
982
|
+
|
|
983
|
+
# Check if E2EE is enabled using the helper function
|
|
984
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
985
|
+
|
|
986
|
+
# Debug logging for E2EE detection
|
|
987
|
+
logger.debug(
|
|
988
|
+
f"E2EE detection: matrix config section present: {'matrix' in config}"
|
|
989
|
+
)
|
|
990
|
+
logger.debug(f"E2EE detection: e2ee enabled = {e2ee_enabled}")
|
|
991
|
+
|
|
992
|
+
if e2ee_enabled:
|
|
721
993
|
# Check if running on Windows
|
|
722
994
|
if sys.platform == WINDOWS_PLATFORM:
|
|
723
995
|
logger.error(
|
|
@@ -733,72 +1005,81 @@ async def connect_matrix(passed_config=None):
|
|
|
733
1005
|
else:
|
|
734
1006
|
# Check if python-olm is installed
|
|
735
1007
|
try:
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
# Also check for other required E2EE dependencies
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1008
|
+
importlib.import_module("olm")
|
|
1009
|
+
|
|
1010
|
+
# Also check for other required E2EE dependencies unless tests skip them
|
|
1011
|
+
if os.getenv("MMRELAY_TESTING") != "1":
|
|
1012
|
+
try:
|
|
1013
|
+
from nio.crypto import OlmDevice as _OlmDevice
|
|
1014
|
+
from nio.store import SqliteStore as _SqliteStore
|
|
1015
|
+
|
|
1016
|
+
logger.debug("All E2EE dependencies are available")
|
|
1017
|
+
except ImportError:
|
|
1018
|
+
logger.exception("Missing E2EE dependency")
|
|
1019
|
+
logger.error(
|
|
1020
|
+
"Please reinstall with: pipx install 'mmrelay[e2e]'"
|
|
1021
|
+
)
|
|
1022
|
+
logger.warning("E2EE will be disabled for this session.")
|
|
1023
|
+
e2ee_enabled = False
|
|
1024
|
+
else:
|
|
1025
|
+
# Dependencies are available, keep the config-determined value
|
|
1026
|
+
if e2ee_enabled:
|
|
1027
|
+
logger.info("End-to-End Encryption (E2EE) is enabled")
|
|
1028
|
+
else:
|
|
1029
|
+
logger.debug(
|
|
1030
|
+
"E2EE dependencies available but E2EE is disabled in configuration"
|
|
1031
|
+
)
|
|
1032
|
+
else:
|
|
1033
|
+
logger.debug(
|
|
1034
|
+
"Skipping additional E2EE dependency imports in test mode"
|
|
748
1035
|
)
|
|
749
|
-
raise RuntimeError(
|
|
750
|
-
"Missing E2EE dependency (Olm/SqliteStore)"
|
|
751
|
-
) from e
|
|
752
1036
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
config["matrix"]
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
config
|
|
770
|
-
)
|
|
771
|
-
else:
|
|
772
|
-
from mmrelay.config import get_e2ee_store_dir
|
|
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
|
+
from mmrelay.config import get_e2ee_store_dir
|
|
773
1054
|
|
|
774
|
-
|
|
1055
|
+
e2ee_store_path = get_e2ee_store_dir()
|
|
775
1056
|
|
|
776
|
-
|
|
777
|
-
|
|
1057
|
+
# Create store directory if it doesn't exist
|
|
1058
|
+
os.makedirs(e2ee_store_path, exist_ok=True)
|
|
778
1059
|
|
|
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."
|
|
1060
|
+
# Check if store directory contains database files
|
|
1061
|
+
store_files = (
|
|
1062
|
+
os.listdir(e2ee_store_path)
|
|
1063
|
+
if os.path.exists(e2ee_store_path)
|
|
1064
|
+
else []
|
|
793
1065
|
)
|
|
1066
|
+
db_files = [f for f in store_files if f.endswith(".db")]
|
|
1067
|
+
if db_files:
|
|
1068
|
+
logger.debug(
|
|
1069
|
+
f"Found existing E2EE store files: {', '.join(db_files)}"
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
logger.warning(
|
|
1073
|
+
"No existing E2EE store files found. Encryption may not work correctly."
|
|
1074
|
+
)
|
|
794
1075
|
|
|
795
|
-
|
|
1076
|
+
logger.debug(f"Using E2EE store path: {e2ee_store_path}")
|
|
796
1077
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1078
|
+
# If device_id is not present in credentials, we can attempt to learn it later.
|
|
1079
|
+
if not e2ee_device_id:
|
|
1080
|
+
logger.debug(
|
|
1081
|
+
"No device_id in credentials; will retrieve from store/whoami later if available"
|
|
1082
|
+
)
|
|
802
1083
|
except ImportError:
|
|
803
1084
|
logger.warning(
|
|
804
1085
|
"E2EE is enabled in config but python-olm is not installed."
|
|
@@ -871,8 +1152,8 @@ async def connect_matrix(passed_config=None):
|
|
|
871
1152
|
logger.info("Encryption keys uploaded successfully")
|
|
872
1153
|
else:
|
|
873
1154
|
logger.debug("No key upload needed - keys already present")
|
|
874
|
-
except Exception
|
|
875
|
-
logger.
|
|
1155
|
+
except Exception:
|
|
1156
|
+
logger.exception("Failed to upload E2EE keys")
|
|
876
1157
|
# E2EE might still work, so we don't disable it here
|
|
877
1158
|
logger.error("Consider regenerating credentials with: mmrelay auth login")
|
|
878
1159
|
|
|
@@ -889,8 +1170,32 @@ async def connect_matrix(passed_config=None):
|
|
|
889
1170
|
hasattr(sync_response, "__class__")
|
|
890
1171
|
and "Error" in sync_response.__class__.__name__
|
|
891
1172
|
):
|
|
892
|
-
|
|
893
|
-
|
|
1173
|
+
# Provide more detailed error information
|
|
1174
|
+
error_type = sync_response.__class__.__name__
|
|
1175
|
+
error_details = _get_detailed_sync_error_message(sync_response)
|
|
1176
|
+
logger.error(f"Initial sync failed: {error_type}")
|
|
1177
|
+
logger.error(f"Error details: {error_details}")
|
|
1178
|
+
|
|
1179
|
+
# Provide user-friendly troubleshooting guidance
|
|
1180
|
+
if "SyncError" in error_type:
|
|
1181
|
+
logger.error(
|
|
1182
|
+
"This usually indicates a network connectivity issue or server problem."
|
|
1183
|
+
)
|
|
1184
|
+
logger.error("Troubleshooting steps:")
|
|
1185
|
+
logger.error("1. Check your internet connection")
|
|
1186
|
+
logger.error(
|
|
1187
|
+
f"2. Verify the homeserver URL is correct: {matrix_homeserver}"
|
|
1188
|
+
)
|
|
1189
|
+
logger.error("3. Ensure the Matrix server is online and accessible")
|
|
1190
|
+
logger.error("4. Check if your credentials are still valid")
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
await matrix_client.close()
|
|
1194
|
+
except Exception:
|
|
1195
|
+
logger.debug("Ignoring error while closing client after sync failure")
|
|
1196
|
+
finally:
|
|
1197
|
+
matrix_client = None
|
|
1198
|
+
raise ConnectionError(f"Matrix sync failed: {error_type} - {error_details}")
|
|
894
1199
|
else:
|
|
895
1200
|
logger.info(
|
|
896
1201
|
f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
|
|
@@ -906,6 +1211,35 @@ async def connect_matrix(passed_config=None):
|
|
|
906
1211
|
# Get comprehensive E2EE status
|
|
907
1212
|
e2ee_status = get_e2ee_status(config, config_path)
|
|
908
1213
|
|
|
1214
|
+
# Resolve room aliases in config (supports list[str|dict] and dict[str->str|dict])
|
|
1215
|
+
async def _resolve_alias(alias: str) -> Optional[str]:
|
|
1216
|
+
"""
|
|
1217
|
+
Resolve a Matrix room alias to its canonical room ID.
|
|
1218
|
+
|
|
1219
|
+
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).
|
|
1220
|
+
"""
|
|
1221
|
+
logger.debug(f"Resolving alias from config: {alias}")
|
|
1222
|
+
try:
|
|
1223
|
+
response = await matrix_client.room_resolve_alias(alias)
|
|
1224
|
+
if hasattr(response, "room_id") and response.room_id:
|
|
1225
|
+
logger.debug(f"Resolved alias {alias} to {response.room_id}")
|
|
1226
|
+
return response.room_id
|
|
1227
|
+
logger.warning(
|
|
1228
|
+
f"Could not resolve alias {alias}: {getattr(response, 'message', response)}"
|
|
1229
|
+
)
|
|
1230
|
+
except (
|
|
1231
|
+
NioErrorResponse,
|
|
1232
|
+
NioLocalProtocolError,
|
|
1233
|
+
NioRemoteProtocolError,
|
|
1234
|
+
NioLocalTransportError,
|
|
1235
|
+
NioRemoteTransportError,
|
|
1236
|
+
asyncio.TimeoutError,
|
|
1237
|
+
):
|
|
1238
|
+
logger.exception(f"Error resolving alias {alias}")
|
|
1239
|
+
return None
|
|
1240
|
+
|
|
1241
|
+
await _resolve_aliases_in_mapping(matrix_rooms, _resolve_alias)
|
|
1242
|
+
|
|
909
1243
|
# Display rooms with channel mappings
|
|
910
1244
|
_display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
|
|
911
1245
|
|
|
@@ -929,10 +1263,30 @@ async def connect_matrix(passed_config=None):
|
|
|
929
1263
|
if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
|
|
930
1264
|
logger.debug("No encrypted rooms detected - all rooms are plaintext")
|
|
931
1265
|
except asyncio.TimeoutError:
|
|
932
|
-
logger.
|
|
1266
|
+
logger.exception(
|
|
933
1267
|
f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
|
|
934
1268
|
)
|
|
935
|
-
|
|
1269
|
+
logger.error(
|
|
1270
|
+
"This indicates a network connectivity issue or slow Matrix server."
|
|
1271
|
+
)
|
|
1272
|
+
logger.error("Troubleshooting steps:")
|
|
1273
|
+
logger.error("1. Check your internet connection")
|
|
1274
|
+
logger.error(f"2. Verify the homeserver is accessible: {matrix_homeserver}")
|
|
1275
|
+
logger.error(
|
|
1276
|
+
"3. Try again in a few minutes - the server may be temporarily overloaded"
|
|
1277
|
+
)
|
|
1278
|
+
logger.error(
|
|
1279
|
+
"4. Consider using a different Matrix homeserver if the problem persists"
|
|
1280
|
+
)
|
|
1281
|
+
try:
|
|
1282
|
+
await matrix_client.close()
|
|
1283
|
+
except Exception:
|
|
1284
|
+
logger.debug("Ignoring error while closing client after sync timeout")
|
|
1285
|
+
finally:
|
|
1286
|
+
matrix_client = None
|
|
1287
|
+
raise ConnectionError(
|
|
1288
|
+
f"Matrix sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds - check network connectivity and server status"
|
|
1289
|
+
) from None
|
|
936
1290
|
|
|
937
1291
|
# Add a delay to allow for key sharing to complete
|
|
938
1292
|
# This addresses a race condition where the client attempts to send encrypted messages
|
|
@@ -965,9 +1319,9 @@ async def login_matrix_bot(
|
|
|
965
1319
|
homeserver=None, username=None, password=None, logout_others=False
|
|
966
1320
|
):
|
|
967
1321
|
"""
|
|
968
|
-
Perform an interactive Matrix login for the bot
|
|
1322
|
+
Perform an interactive Matrix login for the bot and persist credentials for later use.
|
|
969
1323
|
|
|
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.
|
|
1324
|
+
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
1325
|
|
|
972
1326
|
Parameters:
|
|
973
1327
|
homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
|
|
@@ -979,11 +1333,13 @@ async def login_matrix_bot(
|
|
|
979
1333
|
bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
|
|
980
1334
|
"""
|
|
981
1335
|
try:
|
|
982
|
-
#
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1336
|
+
# Optionally enable verbose nio/aiohttp debug logging
|
|
1337
|
+
if os.getenv("MMRELAY_DEBUG_NIO") == "1":
|
|
1338
|
+
logging.getLogger("nio").setLevel(logging.DEBUG)
|
|
1339
|
+
logging.getLogger("nio.client").setLevel(logging.DEBUG)
|
|
1340
|
+
logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
|
|
1341
|
+
logging.getLogger("nio.responses").setLevel(logging.DEBUG)
|
|
1342
|
+
logging.getLogger("aiohttp").setLevel(logging.DEBUG)
|
|
987
1343
|
|
|
988
1344
|
# Get homeserver URL
|
|
989
1345
|
if not homeserver:
|
|
@@ -1004,6 +1360,10 @@ async def login_matrix_bot(
|
|
|
1004
1360
|
logger.warning(
|
|
1005
1361
|
"Failed to create SSL context for server discovery; falling back to default system SSL"
|
|
1006
1362
|
)
|
|
1363
|
+
else:
|
|
1364
|
+
logger.debug(f"SSL context created successfully: {ssl_context}")
|
|
1365
|
+
logger.debug(f"SSL context protocol: {ssl_context.protocol}")
|
|
1366
|
+
logger.debug(f"SSL context verify_mode: {ssl_context.verify_mode}")
|
|
1007
1367
|
|
|
1008
1368
|
# Create a temporary client for discovery
|
|
1009
1369
|
temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
|
|
@@ -1012,15 +1372,30 @@ async def login_matrix_bot(
|
|
|
1012
1372
|
temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
|
|
1013
1373
|
)
|
|
1014
1374
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1375
|
+
try:
|
|
1376
|
+
if isinstance(discovery_response, DiscoveryInfoResponse):
|
|
1377
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1378
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1379
|
+
homeserver = actual_homeserver
|
|
1380
|
+
elif isinstance(discovery_response, DiscoveryInfoError):
|
|
1381
|
+
logger.info(
|
|
1382
|
+
f"Server discovery failed, using original URL: {homeserver}"
|
|
1383
|
+
)
|
|
1384
|
+
# Continue with original homeserver URL
|
|
1385
|
+
else:
|
|
1386
|
+
# Fallback for test environments or unexpected response types
|
|
1387
|
+
if hasattr(discovery_response, "homeserver_url"):
|
|
1388
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1389
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1390
|
+
homeserver = actual_homeserver
|
|
1391
|
+
else:
|
|
1392
|
+
logger.warning(
|
|
1393
|
+
f"Server discovery returned unexpected response type, using original URL: {homeserver}"
|
|
1394
|
+
)
|
|
1395
|
+
except TypeError as e:
|
|
1396
|
+
logger.warning(
|
|
1397
|
+
f"Server discovery error: {e}, using original URL: {homeserver}"
|
|
1022
1398
|
)
|
|
1023
|
-
# Continue with original homeserver URL
|
|
1024
1399
|
|
|
1025
1400
|
except asyncio.TimeoutError:
|
|
1026
1401
|
logger.warning(
|
|
@@ -1040,19 +1415,37 @@ async def login_matrix_bot(
|
|
|
1040
1415
|
username = input("Enter Matrix username (without @): ")
|
|
1041
1416
|
|
|
1042
1417
|
# 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}"
|
|
1418
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
1049
1419
|
|
|
1050
1420
|
logger.info(f"Using username: {username}")
|
|
1051
1421
|
|
|
1422
|
+
# Validate username format
|
|
1423
|
+
if not username.startswith("@"):
|
|
1424
|
+
logger.warning(f"Username doesn't start with @: {username}")
|
|
1425
|
+
if username.count(":") != 1:
|
|
1426
|
+
logger.warning(
|
|
1427
|
+
f"Username has unexpected colon count: {username.count(':')}"
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
# Check for special characters in username that might cause issues
|
|
1431
|
+
username_special_chars = set(username) - set(
|
|
1432
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@:.-_"
|
|
1433
|
+
)
|
|
1434
|
+
if username_special_chars:
|
|
1435
|
+
logger.warning(
|
|
1436
|
+
f"Username contains unusual characters: {username_special_chars}"
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1052
1439
|
# Get password
|
|
1053
1440
|
if not password:
|
|
1054
1441
|
password = getpass.getpass("Enter Matrix password: ")
|
|
1055
1442
|
|
|
1443
|
+
# Simple password validation without logging sensitive information
|
|
1444
|
+
if password:
|
|
1445
|
+
logger.debug("Password provided for login")
|
|
1446
|
+
else:
|
|
1447
|
+
logger.warning("No password provided")
|
|
1448
|
+
|
|
1056
1449
|
# Ask about logging out other sessions
|
|
1057
1450
|
if logout_others is None:
|
|
1058
1451
|
logout_others_input = input(
|
|
@@ -1065,11 +1458,13 @@ async def login_matrix_bot(
|
|
|
1065
1458
|
# Check for existing credentials to reuse device_id
|
|
1066
1459
|
existing_device_id = None
|
|
1067
1460
|
try:
|
|
1461
|
+
import json
|
|
1462
|
+
|
|
1068
1463
|
config_dir = get_base_dir()
|
|
1069
1464
|
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1070
1465
|
|
|
1071
1466
|
if os.path.exists(credentials_path):
|
|
1072
|
-
with open(credentials_path, "r") as f:
|
|
1467
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
1073
1468
|
existing_creds = json.load(f)
|
|
1074
1469
|
if (
|
|
1075
1470
|
"device_id" in existing_creds
|
|
@@ -1080,14 +1475,30 @@ async def login_matrix_bot(
|
|
|
1080
1475
|
except Exception as e:
|
|
1081
1476
|
logger.debug(f"Could not load existing credentials: {e}")
|
|
1082
1477
|
|
|
1083
|
-
#
|
|
1084
|
-
|
|
1085
|
-
os.makedirs(store_path, exist_ok=True)
|
|
1086
|
-
logger.debug(f"Using E2EE store path: {store_path}")
|
|
1478
|
+
# Check if E2EE is enabled in configuration
|
|
1479
|
+
from mmrelay.config import is_e2ee_enabled, load_config
|
|
1087
1480
|
|
|
1088
|
-
|
|
1481
|
+
try:
|
|
1482
|
+
config = load_config()
|
|
1483
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
1484
|
+
except Exception as e:
|
|
1485
|
+
logger.debug(f"Could not load config for E2EE check: {e}")
|
|
1486
|
+
e2ee_enabled = False
|
|
1487
|
+
|
|
1488
|
+
logger.debug(f"E2EE enabled in config: {e2ee_enabled}")
|
|
1489
|
+
|
|
1490
|
+
# Get the E2EE store path only if E2EE is enabled
|
|
1491
|
+
store_path = None
|
|
1492
|
+
if e2ee_enabled:
|
|
1493
|
+
store_path = get_e2ee_store_dir()
|
|
1494
|
+
os.makedirs(store_path, exist_ok=True)
|
|
1495
|
+
logger.debug(f"Using E2EE store path: {store_path}")
|
|
1496
|
+
else:
|
|
1497
|
+
logger.debug("E2EE disabled in configuration, not using store path")
|
|
1498
|
+
|
|
1499
|
+
# Create client config with E2EE based on configuration
|
|
1089
1500
|
client_config = AsyncClientConfig(
|
|
1090
|
-
store_sync_tokens=True, encryption_enabled=
|
|
1501
|
+
store_sync_tokens=True, encryption_enabled=e2ee_enabled
|
|
1091
1502
|
)
|
|
1092
1503
|
|
|
1093
1504
|
# Use the same SSL context as discovery client
|
|
@@ -1095,6 +1506,13 @@ async def login_matrix_bot(
|
|
|
1095
1506
|
|
|
1096
1507
|
# Initialize client with E2EE support
|
|
1097
1508
|
# Use most common pattern from matrix-nio examples: positional homeserver and user
|
|
1509
|
+
logger.debug("Creating AsyncClient with:")
|
|
1510
|
+
logger.debug(f" homeserver: {homeserver}")
|
|
1511
|
+
logger.debug(f" username: {username}")
|
|
1512
|
+
logger.debug(f" device_id: {existing_device_id}")
|
|
1513
|
+
logger.debug(f" store_path: {store_path}")
|
|
1514
|
+
logger.debug(f" e2ee_enabled: {e2ee_enabled}")
|
|
1515
|
+
|
|
1098
1516
|
client = AsyncClient(
|
|
1099
1517
|
homeserver,
|
|
1100
1518
|
username,
|
|
@@ -1104,45 +1522,183 @@ async def login_matrix_bot(
|
|
|
1104
1522
|
ssl=ssl_context,
|
|
1105
1523
|
)
|
|
1106
1524
|
|
|
1525
|
+
logger.debug("AsyncClient created successfully")
|
|
1526
|
+
|
|
1107
1527
|
logger.info(f"Logging in as {username} to {homeserver}...")
|
|
1108
1528
|
|
|
1109
1529
|
# Login with consistent device name and timeout
|
|
1110
|
-
# Use
|
|
1111
|
-
device_name = "mmrelay-e2ee"
|
|
1530
|
+
# Use appropriate device name based on E2EE configuration
|
|
1531
|
+
device_name = "mmrelay-e2ee" if e2ee_enabled else "mmrelay"
|
|
1112
1532
|
try:
|
|
1113
1533
|
# Set device_id on client if we have an existing one
|
|
1114
1534
|
if existing_device_id:
|
|
1115
1535
|
client.device_id = existing_device_id
|
|
1116
1536
|
|
|
1537
|
+
logger.debug(f"Attempting login to {homeserver} as {username}")
|
|
1538
|
+
logger.debug("Login parameters:")
|
|
1539
|
+
logger.debug(f" device_name: {device_name}")
|
|
1540
|
+
logger.debug(f" password length: {len(password) if password else 0}")
|
|
1541
|
+
logger.debug(f" client.user: {client.user}")
|
|
1542
|
+
logger.debug(f" client.homeserver: {client.homeserver}")
|
|
1543
|
+
|
|
1544
|
+
# Test the API call that matrix-nio will make
|
|
1545
|
+
try:
|
|
1546
|
+
from nio.api import Api
|
|
1547
|
+
|
|
1548
|
+
method, path, data = Api.login(
|
|
1549
|
+
user=username,
|
|
1550
|
+
password=password,
|
|
1551
|
+
device_name=device_name,
|
|
1552
|
+
device_id=existing_device_id,
|
|
1553
|
+
)
|
|
1554
|
+
logger.debug("Matrix API call details:")
|
|
1555
|
+
logger.debug(f" method: {method}")
|
|
1556
|
+
logger.debug(f" path: {path}")
|
|
1557
|
+
logger.debug(f" data length: {len(data) if data else 0}")
|
|
1558
|
+
|
|
1559
|
+
# Parse the JSON to see the structure (without logging the password)
|
|
1560
|
+
import json
|
|
1561
|
+
|
|
1562
|
+
parsed_data = json.loads(data)
|
|
1563
|
+
safe_data = {
|
|
1564
|
+
k: (v if k != "password" else f"[{len(v)} chars]")
|
|
1565
|
+
for k, v in parsed_data.items()
|
|
1566
|
+
}
|
|
1567
|
+
logger.debug(f" parsed data: {safe_data}")
|
|
1568
|
+
|
|
1569
|
+
except Exception as e:
|
|
1570
|
+
logger.error(f"Failed to test API call: {e}")
|
|
1571
|
+
|
|
1117
1572
|
response = await asyncio.wait_for(
|
|
1118
1573
|
client.login(password, device_name=device_name),
|
|
1119
1574
|
timeout=MATRIX_LOGIN_TIMEOUT,
|
|
1120
1575
|
)
|
|
1576
|
+
|
|
1577
|
+
# Debug: Log the response type and safe attributes only
|
|
1578
|
+
logger.debug(f"Login response type: {type(response).__name__}")
|
|
1579
|
+
|
|
1580
|
+
# Check specific attributes that should be present, masking sensitive data
|
|
1581
|
+
for attr in [
|
|
1582
|
+
"user_id",
|
|
1583
|
+
"device_id",
|
|
1584
|
+
"access_token",
|
|
1585
|
+
"status_code",
|
|
1586
|
+
"message",
|
|
1587
|
+
]:
|
|
1588
|
+
if hasattr(response, attr):
|
|
1589
|
+
value = getattr(response, attr)
|
|
1590
|
+
if attr == "access_token" and value:
|
|
1591
|
+
# Mask access token for security
|
|
1592
|
+
masked_value = (
|
|
1593
|
+
f"{value[:8]}...{value[-4:]}"
|
|
1594
|
+
if len(value) > 12
|
|
1595
|
+
else "***masked***"
|
|
1596
|
+
)
|
|
1597
|
+
logger.debug(
|
|
1598
|
+
f"Response.{attr}: {masked_value} (type: {type(value).__name__})"
|
|
1599
|
+
)
|
|
1600
|
+
else:
|
|
1601
|
+
logger.debug(
|
|
1602
|
+
f"Response.{attr}: {value} (type: {type(value).__name__})"
|
|
1603
|
+
)
|
|
1604
|
+
else:
|
|
1605
|
+
logger.debug(f"Response.{attr}: NOT PRESENT")
|
|
1121
1606
|
except asyncio.TimeoutError:
|
|
1122
|
-
logger.
|
|
1607
|
+
logger.exception(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
|
|
1123
1608
|
logger.error(
|
|
1124
1609
|
"This may indicate network connectivity issues or a slow Matrix server"
|
|
1125
1610
|
)
|
|
1126
1611
|
await client.close()
|
|
1127
1612
|
return False
|
|
1613
|
+
except TypeError as e:
|
|
1614
|
+
# Handle the specific ">=" comparison error that can occur in matrix-nio
|
|
1615
|
+
if "'>=' not supported between instances of 'str' and 'int'" in str(e):
|
|
1616
|
+
logger.error("Matrix-nio library error during login (known issue)")
|
|
1617
|
+
logger.error(
|
|
1618
|
+
"This typically indicates invalid credentials or server response format issues"
|
|
1619
|
+
)
|
|
1620
|
+
logger.error("Troubleshooting steps:")
|
|
1621
|
+
logger.error("1. Verify your username and password are correct")
|
|
1622
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1623
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1624
|
+
logger.error("4. Ensure your Matrix server supports the login API")
|
|
1625
|
+
logger.error(
|
|
1626
|
+
"5. Try using a different homeserver URL format (e.g., with https://)"
|
|
1627
|
+
)
|
|
1628
|
+
else:
|
|
1629
|
+
logger.exception("Type error during login")
|
|
1630
|
+
await client.close()
|
|
1631
|
+
return False
|
|
1128
1632
|
except Exception as e:
|
|
1129
1633
|
# Handle other exceptions during login (e.g., network errors)
|
|
1130
|
-
|
|
1131
|
-
logger.
|
|
1132
|
-
|
|
1133
|
-
|
|
1634
|
+
error_type = type(e).__name__
|
|
1635
|
+
logger.exception(f"Login failed with {error_type}")
|
|
1636
|
+
|
|
1637
|
+
# Provide specific guidance based on error type
|
|
1638
|
+
if isinstance(e, (ConnectionError, asyncio.TimeoutError)):
|
|
1639
|
+
logger.error("Network connectivity issue detected.")
|
|
1640
|
+
logger.error("Troubleshooting steps:")
|
|
1641
|
+
logger.error("1. Check your internet connection")
|
|
1642
|
+
logger.error(f"2. Verify the homeserver URL is correct: {homeserver}")
|
|
1643
|
+
logger.error("3. Check if the Matrix server is online")
|
|
1644
|
+
elif isinstance(e, (ssl.SSLError, ssl.CertificateError)):
|
|
1645
|
+
logger.error("SSL/TLS certificate issue detected.")
|
|
1646
|
+
logger.error(
|
|
1647
|
+
"This may indicate a problem with the server's SSL certificate."
|
|
1648
|
+
)
|
|
1649
|
+
elif "DNSError" in error_type or "NameResolutionError" in error_type:
|
|
1650
|
+
logger.error("DNS resolution failed.")
|
|
1651
|
+
logger.error(f"Cannot resolve hostname: {homeserver}")
|
|
1652
|
+
logger.error("Check your DNS settings and internet connection.")
|
|
1653
|
+
elif "'user_id' is a required property" in str(e):
|
|
1654
|
+
logger.error("Matrix server response validation failed.")
|
|
1655
|
+
logger.error("This typically indicates:")
|
|
1656
|
+
logger.error("1. Invalid username or password")
|
|
1657
|
+
logger.error("2. Server response format not as expected")
|
|
1658
|
+
logger.error("3. Matrix server compatibility issues")
|
|
1659
|
+
logger.error("Troubleshooting steps:")
|
|
1660
|
+
logger.error("1. Verify credentials by logging in via web browser")
|
|
1661
|
+
logger.error(
|
|
1662
|
+
"2. Try using the full homeserver URL (e.g., https://matrix.org)"
|
|
1663
|
+
)
|
|
1664
|
+
logger.error(
|
|
1665
|
+
"3. Check if your Matrix server is compatible with matrix-nio"
|
|
1666
|
+
)
|
|
1667
|
+
logger.error("4. Try a different Matrix server if available")
|
|
1668
|
+
|
|
1669
|
+
else:
|
|
1670
|
+
logger.error("Unexpected error during login.")
|
|
1671
|
+
|
|
1672
|
+
# Additional details already included in the message above.
|
|
1134
1673
|
await client.close()
|
|
1135
1674
|
return False
|
|
1136
1675
|
|
|
1137
|
-
|
|
1676
|
+
# Handle login response - check for access_token first (most reliable indicator)
|
|
1677
|
+
if hasattr(response, "access_token") and response.access_token:
|
|
1138
1678
|
logger.info("Login successful!")
|
|
1139
1679
|
|
|
1680
|
+
# Get the actual user_id from whoami() - this is the proper way
|
|
1681
|
+
try:
|
|
1682
|
+
whoami_response = await client.whoami()
|
|
1683
|
+
if hasattr(whoami_response, "user_id"):
|
|
1684
|
+
actual_user_id = whoami_response.user_id
|
|
1685
|
+
logger.debug(f"Got user_id from whoami: {actual_user_id}")
|
|
1686
|
+
else:
|
|
1687
|
+
# Fallback to response user_id or username
|
|
1688
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1689
|
+
logger.warning(
|
|
1690
|
+
f"whoami failed, using fallback user_id: {actual_user_id}"
|
|
1691
|
+
)
|
|
1692
|
+
except Exception as e:
|
|
1693
|
+
logger.warning(f"whoami call failed: {e}, using fallback")
|
|
1694
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1695
|
+
|
|
1140
1696
|
# Save credentials to credentials.json
|
|
1141
1697
|
credentials = {
|
|
1142
1698
|
"homeserver": homeserver,
|
|
1143
|
-
"user_id":
|
|
1699
|
+
"user_id": actual_user_id,
|
|
1144
1700
|
"access_token": response.access_token,
|
|
1145
|
-
"device_id": response
|
|
1701
|
+
"device_id": getattr(response, "device_id", existing_device_id),
|
|
1146
1702
|
}
|
|
1147
1703
|
|
|
1148
1704
|
config_dir = get_base_dir()
|
|
@@ -1159,66 +1715,171 @@ async def login_matrix_bot(
|
|
|
1159
1715
|
await client.close()
|
|
1160
1716
|
return True
|
|
1161
1717
|
else:
|
|
1162
|
-
#
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
logger.error(f"
|
|
1718
|
+
# Handle login failure
|
|
1719
|
+
if hasattr(response, "status_code") and hasattr(response, "message"):
|
|
1720
|
+
status_code = response.status_code
|
|
1721
|
+
error_message = response.message
|
|
1722
|
+
|
|
1723
|
+
logger.error(f"Login failed: {type(response).__name__}")
|
|
1724
|
+
logger.error(f"Error message: {error_message}")
|
|
1725
|
+
logger.error(f"HTTP status code: {status_code}")
|
|
1726
|
+
|
|
1727
|
+
# Provide specific troubleshooting guidance
|
|
1728
|
+
if status_code == 401 or "M_FORBIDDEN" in str(error_message):
|
|
1729
|
+
logger.error(
|
|
1730
|
+
"Authentication failed - invalid username or password."
|
|
1731
|
+
)
|
|
1732
|
+
logger.error("Troubleshooting steps:")
|
|
1733
|
+
logger.error("1. Verify your username and password are correct")
|
|
1734
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1735
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1736
|
+
logger.error(
|
|
1737
|
+
"4. Use 'mmrelay auth login' to set up new credentials"
|
|
1738
|
+
)
|
|
1739
|
+
elif status_code == 404:
|
|
1740
|
+
logger.error("User not found or homeserver not found.")
|
|
1741
|
+
logger.error(
|
|
1742
|
+
f"Check that the homeserver URL is correct: {homeserver}"
|
|
1743
|
+
)
|
|
1744
|
+
elif status_code == 429:
|
|
1745
|
+
logger.error("Rate limited - too many login attempts.")
|
|
1746
|
+
logger.error("Wait a few minutes before trying again.")
|
|
1747
|
+
elif status_code and int(status_code) >= 500:
|
|
1748
|
+
logger.error(
|
|
1749
|
+
"Matrix server error - the server is experiencing issues."
|
|
1750
|
+
)
|
|
1751
|
+
logger.error(
|
|
1752
|
+
"Try again later or contact your server administrator."
|
|
1753
|
+
)
|
|
1754
|
+
else:
|
|
1755
|
+
logger.error("Login failed for unknown reason.")
|
|
1756
|
+
logger.error(
|
|
1757
|
+
"Try using 'mmrelay auth login' for interactive setup."
|
|
1758
|
+
)
|
|
1759
|
+
else:
|
|
1760
|
+
logger.error(f"Unexpected login response: {type(response).__name__}")
|
|
1761
|
+
logger.error(
|
|
1762
|
+
"This may indicate a matrix-nio library issue or server problem."
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1168
1765
|
await client.close()
|
|
1169
1766
|
return False
|
|
1170
1767
|
|
|
1171
|
-
except Exception
|
|
1172
|
-
logger.
|
|
1768
|
+
except Exception:
|
|
1769
|
+
logger.exception("Error during login")
|
|
1770
|
+
try:
|
|
1771
|
+
await client.close()
|
|
1772
|
+
except Exception as e:
|
|
1773
|
+
# Ignore errors during client cleanup - connection may already be closed
|
|
1774
|
+
logger.debug(f"Ignoring error during client cleanup: {e}")
|
|
1173
1775
|
return False
|
|
1174
1776
|
|
|
1175
1777
|
|
|
1176
1778
|
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
1177
1779
|
"""
|
|
1178
|
-
Join a Matrix room by ID or alias
|
|
1780
|
+
Join the bot to a Matrix room by ID or alias.
|
|
1781
|
+
|
|
1782
|
+
Resolves a room alias (e.g. "#room:server") to its canonical room ID, updates the in-memory
|
|
1783
|
+
matrix_rooms mapping with the resolved ID (if available), and attempts to join the resolved
|
|
1784
|
+
room ID. No-op if the client is already joined to the room. Errors during alias resolution
|
|
1785
|
+
or join are caught and logged; the function does not raise exceptions.
|
|
1786
|
+
|
|
1787
|
+
Parameters documented only where meaning is not obvious:
|
|
1788
|
+
room_id_or_alias (str): A Matrix room identifier, either a canonical room ID (e.g. "!abc:server")
|
|
1789
|
+
or a room alias (starts with '#'). When an alias is provided, it will be resolved and
|
|
1790
|
+
the resolved room ID will be used for joining and recorded in the module's matrix_rooms mapping.
|
|
1791
|
+
|
|
1792
|
+
Returns:
|
|
1793
|
+
None
|
|
1794
|
+
"""
|
|
1179
1795
|
|
|
1180
|
-
|
|
1796
|
+
if not isinstance(room_id_or_alias, str):
|
|
1797
|
+
logger.error(
|
|
1798
|
+
"join_matrix_room expected a string room ID, received %r",
|
|
1799
|
+
room_id_or_alias,
|
|
1800
|
+
)
|
|
1801
|
+
return
|
|
1181
1802
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
""
|
|
1185
|
-
|
|
1186
|
-
if room_id_or_alias.startswith("#"):
|
|
1187
|
-
# If it's a room alias, resolve it to a room ID
|
|
1803
|
+
room_id = room_id_or_alias
|
|
1804
|
+
|
|
1805
|
+
if room_id_or_alias.startswith("#"):
|
|
1806
|
+
try:
|
|
1188
1807
|
response = await matrix_client.room_resolve_alias(room_id_or_alias)
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1808
|
+
except (
|
|
1809
|
+
NioLocalProtocolError,
|
|
1810
|
+
NioRemoteProtocolError,
|
|
1811
|
+
NioErrorResponse,
|
|
1812
|
+
NioLocalTransportError,
|
|
1813
|
+
NioRemoteTransportError,
|
|
1814
|
+
asyncio.TimeoutError,
|
|
1815
|
+
):
|
|
1816
|
+
logger.exception("Error resolving alias '%s'", room_id_or_alias)
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
room_id = getattr(response, "room_id", None) if response else None
|
|
1820
|
+
if not room_id:
|
|
1821
|
+
logger.error(
|
|
1822
|
+
"Failed to resolve alias '%s': %s",
|
|
1823
|
+
room_id_or_alias,
|
|
1824
|
+
getattr(response, "message", str(response)),
|
|
1825
|
+
)
|
|
1826
|
+
return
|
|
1827
|
+
|
|
1828
|
+
try:
|
|
1829
|
+
mapping = matrix_rooms
|
|
1830
|
+
except NameError:
|
|
1831
|
+
mapping = None
|
|
1832
|
+
|
|
1833
|
+
if mapping:
|
|
1834
|
+
try:
|
|
1835
|
+
_update_room_id_in_mapping(mapping, room_id_or_alias, room_id)
|
|
1836
|
+
except Exception:
|
|
1837
|
+
logger.debug(
|
|
1838
|
+
"Non-fatal error updating matrix_rooms for alias '%s'",
|
|
1839
|
+
room_id_or_alias,
|
|
1840
|
+
exc_info=True,
|
|
1192
1841
|
)
|
|
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
1842
|
|
|
1203
|
-
|
|
1843
|
+
logger.info("Resolved alias '%s' -> '%s'", room_id_or_alias, room_id)
|
|
1844
|
+
|
|
1845
|
+
try:
|
|
1204
1846
|
if room_id not in matrix_client.rooms:
|
|
1205
1847
|
response = await matrix_client.join(room_id)
|
|
1206
|
-
|
|
1207
|
-
|
|
1848
|
+
joined_room_id = getattr(response, "room_id", None) if response else None
|
|
1849
|
+
if joined_room_id:
|
|
1850
|
+
logger.info(f"Joined room '{joined_room_id}' successfully")
|
|
1208
1851
|
else:
|
|
1209
1852
|
logger.error(
|
|
1210
|
-
|
|
1853
|
+
"Failed to join room '%s': %s",
|
|
1854
|
+
room_id,
|
|
1855
|
+
getattr(response, "message", str(response)),
|
|
1211
1856
|
)
|
|
1212
1857
|
else:
|
|
1213
|
-
logger.debug(
|
|
1214
|
-
|
|
1215
|
-
|
|
1858
|
+
logger.debug(
|
|
1859
|
+
"Bot is already in room '%s', no action needed.",
|
|
1860
|
+
room_id,
|
|
1861
|
+
)
|
|
1862
|
+
except (
|
|
1863
|
+
NioLocalProtocolError,
|
|
1864
|
+
NioRemoteProtocolError,
|
|
1865
|
+
NioErrorResponse,
|
|
1866
|
+
NioLocalTransportError,
|
|
1867
|
+
NioRemoteTransportError,
|
|
1868
|
+
asyncio.TimeoutError,
|
|
1869
|
+
):
|
|
1870
|
+
logger.exception(f"Error joining room '{room_id}'")
|
|
1216
1871
|
|
|
1217
1872
|
|
|
1218
1873
|
def _get_e2ee_error_message():
|
|
1219
1874
|
"""
|
|
1220
|
-
Return a
|
|
1221
|
-
|
|
1875
|
+
Return a user-facing string explaining why End-to-End Encryption (E2EE) is not enabled.
|
|
1876
|
+
|
|
1877
|
+
This queries the unified E2EE status (using the module-level config and config path)
|
|
1878
|
+
and converts that status into a concise error message suitable for logging or UI display.
|
|
1879
|
+
|
|
1880
|
+
Returns:
|
|
1881
|
+
str: A short, human-readable explanation of the current E2EE problem (empty or generic
|
|
1882
|
+
message if no specific issue is detected).
|
|
1222
1883
|
"""
|
|
1223
1884
|
from mmrelay.config import config_path
|
|
1224
1885
|
from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
|
|
@@ -1245,31 +1906,25 @@ async def matrix_relay(
|
|
|
1245
1906
|
reply_to_event_id=None,
|
|
1246
1907
|
):
|
|
1247
1908
|
"""
|
|
1248
|
-
Relay a Meshtastic message into a Matrix room
|
|
1909
|
+
Relay a Meshtastic message into a Matrix room and optionally record a Meshtastic↔Matrix mapping.
|
|
1249
1910
|
|
|
1250
|
-
|
|
1911
|
+
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
1912
|
|
|
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.
|
|
1913
|
+
Parameters that require extra context:
|
|
1914
|
+
- 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.
|
|
1915
|
+
- meshtastic_replyId: original Meshtastic message ID being replied to; included as metadata on the Matrix event.
|
|
1916
|
+
- meshtastic_text: original Meshtastic text used when creating stored mappings (falls back to the relayed message if omitted).
|
|
1917
|
+
- emote: if True, the Matrix message is sent as an `m.emote` (emote) instead of `m.text`.
|
|
1918
|
+
- emoji: if True, a flag is added to the event to indicate emoji-like content.
|
|
1919
|
+
- 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
1920
|
|
|
1266
1921
|
Side effects:
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1922
|
+
- Sends a message to Matrix using the global Matrix client.
|
|
1923
|
+
- May persist a message mapping (Meshtastic ID → Matrix event) when configured.
|
|
1924
|
+
- Logs operational and error information; does not propagate send/storage exceptions to callers.
|
|
1270
1925
|
|
|
1271
1926
|
Returns:
|
|
1272
|
-
|
|
1927
|
+
- None
|
|
1273
1928
|
"""
|
|
1274
1929
|
global config
|
|
1275
1930
|
|
|
@@ -1314,33 +1969,37 @@ async def matrix_relay(
|
|
|
1314
1969
|
has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
|
|
1315
1970
|
has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
|
|
1316
1971
|
|
|
1317
|
-
# Process markdown
|
|
1972
|
+
# Process markdown/HTML if available; otherwise, safe fallback
|
|
1318
1973
|
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
|
-
|
|
1974
|
+
try:
|
|
1975
|
+
import bleach # lazy import
|
|
1976
|
+
import markdown # lazy import
|
|
1977
|
+
|
|
1978
|
+
raw_html = markdown.markdown(message)
|
|
1979
|
+
formatted_body = bleach.clean(
|
|
1980
|
+
raw_html,
|
|
1981
|
+
tags=[
|
|
1982
|
+
"b",
|
|
1983
|
+
"strong",
|
|
1984
|
+
"i",
|
|
1985
|
+
"em",
|
|
1986
|
+
"code",
|
|
1987
|
+
"pre",
|
|
1988
|
+
"br",
|
|
1989
|
+
"blockquote",
|
|
1990
|
+
"a",
|
|
1991
|
+
"ul",
|
|
1992
|
+
"ol",
|
|
1993
|
+
"li",
|
|
1994
|
+
"p",
|
|
1995
|
+
],
|
|
1996
|
+
attributes={"a": ["href"]},
|
|
1997
|
+
strip=True,
|
|
1998
|
+
)
|
|
1999
|
+
plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
|
|
2000
|
+
except ImportError:
|
|
2001
|
+
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
2002
|
+
plain_body = message
|
|
1344
2003
|
else:
|
|
1345
2004
|
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
1346
2005
|
plain_body = message
|
|
@@ -1388,17 +2047,17 @@ async def matrix_relay(
|
|
|
1388
2047
|
r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
|
|
1389
2048
|
)
|
|
1390
2049
|
quoted_text = (
|
|
1391
|
-
f">
|
|
2050
|
+
f"> <{bot_user_id}> [{safe_sender_display}]: {safe_original}"
|
|
1392
2051
|
)
|
|
1393
2052
|
content["body"] = f"{quoted_text}\n\n{plain_body}"
|
|
1394
2053
|
|
|
1395
2054
|
# Always use HTML formatting for replies since we need the mx-reply structure
|
|
1396
2055
|
content["format"] = "org.matrix.custom.html"
|
|
1397
2056
|
reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
|
|
1398
|
-
bot_link = f"https://matrix.to
|
|
2057
|
+
bot_link = f"https://matrix.to/#/{bot_user_id}"
|
|
1399
2058
|
blockquote_content = (
|
|
1400
2059
|
f'<a href="{reply_link}">In reply to</a> '
|
|
1401
|
-
f'<a href="{bot_link}"
|
|
2060
|
+
f'<a href="{bot_link}">{bot_user_id}</a><br>'
|
|
1402
2061
|
f"[{html.escape(original_sender_display)}]: {safe_original}"
|
|
1403
2062
|
)
|
|
1404
2063
|
content["formatted_body"] = (
|
|
@@ -1489,8 +2148,8 @@ async def matrix_relay(
|
|
|
1489
2148
|
except asyncio.TimeoutError:
|
|
1490
2149
|
logger.error(f"Timeout sending message to Matrix room {room_id}")
|
|
1491
2150
|
return
|
|
1492
|
-
except Exception
|
|
1493
|
-
logger.
|
|
2151
|
+
except Exception:
|
|
2152
|
+
logger.exception(f"Error sending message to Matrix room {room_id}")
|
|
1494
2153
|
return
|
|
1495
2154
|
|
|
1496
2155
|
# Only store message map if any interactions are enabled and conditions are met
|
|
@@ -1502,35 +2161,47 @@ async def matrix_relay(
|
|
|
1502
2161
|
and hasattr(response, "event_id")
|
|
1503
2162
|
):
|
|
1504
2163
|
try:
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2164
|
+
loop = asyncio.get_running_loop()
|
|
2165
|
+
# Store the message map in executor
|
|
2166
|
+
await loop.run_in_executor(
|
|
2167
|
+
None,
|
|
2168
|
+
lambda: store_message_map(
|
|
2169
|
+
meshtastic_id,
|
|
2170
|
+
response.event_id,
|
|
2171
|
+
room_id,
|
|
2172
|
+
meshtastic_text if meshtastic_text else message,
|
|
2173
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
2174
|
+
),
|
|
1512
2175
|
)
|
|
1513
2176
|
logger.debug(f"Stored message map for meshtastic_id: {meshtastic_id}")
|
|
1514
2177
|
|
|
1515
2178
|
# If msgs_to_keep > 0, prune old messages after inserting a new one
|
|
1516
2179
|
if msgs_to_keep > 0:
|
|
1517
|
-
prune_message_map
|
|
2180
|
+
await loop.run_in_executor(None, prune_message_map, msgs_to_keep)
|
|
1518
2181
|
except Exception as e:
|
|
1519
2182
|
logger.error(f"Error storing message map: {e}")
|
|
1520
2183
|
|
|
1521
2184
|
except asyncio.TimeoutError:
|
|
1522
2185
|
logger.error("Timed out while waiting for Matrix response")
|
|
1523
|
-
except Exception
|
|
1524
|
-
logger.
|
|
2186
|
+
except Exception:
|
|
2187
|
+
logger.exception(f"Error sending radio message to matrix room {room_id}")
|
|
1525
2188
|
|
|
1526
2189
|
|
|
1527
2190
|
def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
|
|
1528
2191
|
"""
|
|
1529
|
-
Truncate
|
|
2192
|
+
Truncate a string so its UTF-8 encoding fits within max_bytes.
|
|
2193
|
+
|
|
2194
|
+
Returns a substring whose UTF-8 byte length is at most `max_bytes`. If
|
|
2195
|
+
`max_bytes` falls in the middle of a multi-byte UTF-8 character, the
|
|
2196
|
+
incomplete character is dropped (decoding uses 'ignore').
|
|
1530
2197
|
|
|
1531
|
-
:
|
|
1532
|
-
|
|
1533
|
-
|
|
2198
|
+
Parameters:
|
|
2199
|
+
text (str): Input text to truncate.
|
|
2200
|
+
max_bytes (int): Maximum allowed size in bytes for the UTF-8 encoded result
|
|
2201
|
+
(defaults to DEFAULT_MESSAGE_TRUNCATE_BYTES).
|
|
2202
|
+
|
|
2203
|
+
Returns:
|
|
2204
|
+
str: Truncated string.
|
|
1534
2205
|
"""
|
|
1535
2206
|
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
|
|
1536
2207
|
return truncated_text
|
|
@@ -1543,16 +2214,21 @@ def strip_quoted_lines(text: str) -> str:
|
|
|
1543
2214
|
This is typically used to exclude quoted content from Matrix replies, such as when processing reaction text.
|
|
1544
2215
|
"""
|
|
1545
2216
|
lines = text.splitlines()
|
|
1546
|
-
filtered = [line for line in lines if not line.strip().startswith(">")]
|
|
1547
|
-
return " ".join(filtered).strip()
|
|
2217
|
+
filtered = [line.strip() for line in lines if not line.strip().startswith(">")]
|
|
2218
|
+
return " ".join(line for line in filtered if line).strip()
|
|
1548
2219
|
|
|
1549
2220
|
|
|
1550
2221
|
async def get_user_display_name(room, event):
|
|
1551
2222
|
"""
|
|
1552
|
-
|
|
1553
|
-
|
|
2223
|
+
Return the display name for the event sender, preferring a room-specific name.
|
|
2224
|
+
|
|
2225
|
+
If the room provides a per-room display name for the sender, that name is returned.
|
|
2226
|
+
Otherwise the function performs an asynchronous lookup against the homeserver for the
|
|
2227
|
+
user's global display name and returns it if present. If no display name is available,
|
|
2228
|
+
the sender's Matrix ID (MXID) is returned.
|
|
2229
|
+
|
|
1554
2230
|
Returns:
|
|
1555
|
-
str:
|
|
2231
|
+
str: A human-readable display name or the sender's MXID.
|
|
1556
2232
|
"""
|
|
1557
2233
|
room_display_name = room.user_name(event.sender)
|
|
1558
2234
|
if room_display_name:
|
|
@@ -1562,7 +2238,17 @@ async def get_user_display_name(room, event):
|
|
|
1562
2238
|
return display_name_response.displayname or event.sender
|
|
1563
2239
|
|
|
1564
2240
|
|
|
1565
|
-
def format_reply_message(
|
|
2241
|
+
def format_reply_message(
|
|
2242
|
+
config,
|
|
2243
|
+
full_display_name,
|
|
2244
|
+
text,
|
|
2245
|
+
*,
|
|
2246
|
+
longname=None,
|
|
2247
|
+
shortname=None,
|
|
2248
|
+
meshnet_name=None,
|
|
2249
|
+
local_meshnet_name=None,
|
|
2250
|
+
mesh_text_override=None,
|
|
2251
|
+
):
|
|
1566
2252
|
"""
|
|
1567
2253
|
Format a reply message by prefixing a truncated display name and removing quoted lines.
|
|
1568
2254
|
|
|
@@ -1575,11 +2261,50 @@ def format_reply_message(config, full_display_name, text):
|
|
|
1575
2261
|
Returns:
|
|
1576
2262
|
str: The formatted and truncated reply message.
|
|
1577
2263
|
"""
|
|
1578
|
-
|
|
2264
|
+
# Determine the base text to use (prefer the raw Meshtastic payload when present)
|
|
2265
|
+
base_text = mesh_text_override if mesh_text_override else text
|
|
2266
|
+
|
|
2267
|
+
clean_text = strip_quoted_lines(base_text).strip()
|
|
2268
|
+
|
|
2269
|
+
# Handle remote meshnet replies by using the remote sender's prefix format
|
|
2270
|
+
if meshnet_name and local_meshnet_name and meshnet_name != local_meshnet_name:
|
|
2271
|
+
sender_long = longname or full_display_name or shortname or "???"
|
|
2272
|
+
sender_short = shortname or sender_long[:SHORTNAME_FALLBACK_LENGTH] or "???"
|
|
2273
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
2274
|
+
|
|
2275
|
+
prefix_candidates = [
|
|
2276
|
+
f"[{sender_long}/{meshnet_name}]: ",
|
|
2277
|
+
f"[{sender_long}/{short_meshnet_name}]: ",
|
|
2278
|
+
f"{sender_long}/{meshnet_name}: ",
|
|
2279
|
+
f"{sender_long}/{short_meshnet_name}: ",
|
|
2280
|
+
f"{sender_short}/{meshnet_name}: ",
|
|
2281
|
+
f"{sender_short}/{short_meshnet_name}: ",
|
|
2282
|
+
]
|
|
2283
|
+
|
|
2284
|
+
matrix_prefix_full = get_matrix_prefix(
|
|
2285
|
+
config, sender_long, sender_short, meshnet_name
|
|
2286
|
+
)
|
|
2287
|
+
matrix_prefix_short = get_matrix_prefix(
|
|
2288
|
+
config, sender_long, sender_short, short_meshnet_name
|
|
2289
|
+
)
|
|
2290
|
+
prefix_candidates.extend([matrix_prefix_full, matrix_prefix_short])
|
|
2291
|
+
|
|
2292
|
+
for candidate in prefix_candidates:
|
|
2293
|
+
if candidate and clean_text.startswith(candidate):
|
|
2294
|
+
clean_text = clean_text[len(candidate) :].lstrip()
|
|
2295
|
+
break
|
|
2296
|
+
|
|
2297
|
+
if not clean_text and mesh_text_override:
|
|
2298
|
+
clean_text = strip_quoted_lines(mesh_text_override).strip()
|
|
2299
|
+
|
|
2300
|
+
mesh_prefix = f"{sender_short}/{short_meshnet_name}:"
|
|
2301
|
+
reply_body = f" {clean_text}" if clean_text else ""
|
|
2302
|
+
reply_message = f"{mesh_prefix}{reply_body}"
|
|
2303
|
+
return truncate_message(reply_message.strip())
|
|
1579
2304
|
|
|
1580
|
-
#
|
|
1581
|
-
|
|
1582
|
-
reply_message = f"{prefix}{clean_text}"
|
|
2305
|
+
# Default behavior for local Matrix users (retain existing prefix logic)
|
|
2306
|
+
prefix = get_meshtastic_prefix(config, full_display_name)
|
|
2307
|
+
reply_message = f"{prefix}{clean_text}" if clean_text else prefix.rstrip()
|
|
1583
2308
|
return truncate_message(reply_message)
|
|
1584
2309
|
|
|
1585
2310
|
|
|
@@ -1595,29 +2320,18 @@ async def send_reply_to_meshtastic(
|
|
|
1595
2320
|
reply_id=None,
|
|
1596
2321
|
):
|
|
1597
2322
|
"""
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
If Meshtastic broadcasting is disabled
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
event: Matrix event object being replied to (its event_id is used for mapping metadata).
|
|
1608
|
-
text (str): Original Matrix event text (used when creating mapping metadata).
|
|
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.
|
|
2323
|
+
Enqueue a Matrix reply for transmission over Meshtastic, either as a structured reply or a regular broadcast.
|
|
2324
|
+
|
|
2325
|
+
If Meshtastic broadcasting is disabled the function returns without action. 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 when possible. If reply_id is provided the message is sent as a structured reply targeting that Meshtastic message ID; otherwise it is sent as a regular text broadcast. Failures are logged; the function does not raise on enqueue errors.
|
|
2326
|
+
Parameters that add non-obvious context:
|
|
2327
|
+
room_config (dict): Room-specific configuration — must include "meshtastic_channel" (an integer channel index).
|
|
2328
|
+
room: Matrix room object (room.room_id is used for mapping metadata).
|
|
2329
|
+
event: Matrix event object (event.event_id is used for mapping metadata).
|
|
2330
|
+
storage_enabled (bool): When True, attempt to create and attach a message-mapping record to the queued item.
|
|
2331
|
+
reply_id (int | None): If provided, send as a structured reply targeting this Meshtastic message ID.
|
|
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
|
+
Relay a Matrix reply back to Meshtastic when the replied-to Matrix event maps to a Meshtastic message.
|
|
2437
|
+
|
|
2438
|
+
If the replied-to Matrix event has a stored Meshtastic mapping, format a Meshtastic reply preserving sender attribution and queue it as a reply that references the original Meshtastic message ID. If no mapping exists, do nothing and return 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 corresponding Meshtastic mapping.
|
|
2442
|
+
storage_enabled (bool): If True, message mappings may be created/updated when sending the reply.
|
|
2443
|
+
local_meshnet_name (str): Local meshnet name used to determine cross-meshnet reply formatting.
|
|
2444
|
+
config (dict): Relay configuration passed to formatting routines.
|
|
2445
|
+
mesh_text_override (str | None): Optional override text to send to Meshtastic instead of derived text.
|
|
2446
|
+
longname (str | None): Sender long display name used for prefixing in the Meshtastic message.
|
|
2447
|
+
shortname (str | None): Sender short display name used for prefixing in the Meshtastic message.
|
|
2448
|
+
meshnet_name (str | None): Remote meshnet name of the original Matrix/meshtastic 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,23 +2545,21 @@ async def on_room_message(
|
|
|
1804
2545
|
],
|
|
1805
2546
|
) -> None:
|
|
1806
2547
|
"""
|
|
1807
|
-
Handle an incoming Matrix room event and relay
|
|
1808
|
-
|
|
1809
|
-
Processes
|
|
2548
|
+
Handle an incoming Matrix room event and, when applicable, relay it to Meshtastic.
|
|
2549
|
+
|
|
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
|
-
|
|
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.
|
|
1818
|
-
|
|
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.
|
|
2557
|
+
|
|
1819
2558
|
Side effects:
|
|
1820
|
-
- May enqueue
|
|
1821
|
-
- May read
|
|
1822
|
-
- May call Matrix APIs to fetch display names.
|
|
1823
|
-
|
|
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).
|
|
2562
|
+
|
|
1824
2563
|
Returns:
|
|
1825
2564
|
- None
|
|
1826
2565
|
"""
|
|
@@ -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:
|