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/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 a metadata dictionary linking a Matrix event to a Meshtastic message for message mapping.
500
+ Create metadata linking a Matrix event to a Meshtastic message for cross-network mapping.
250
501
 
251
- Removes quoted lines from the message text and includes identifiers and message retention settings. Returns `None` if any required parameter is missing.
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: Mapping information for the message queue, or `None` if required fields are missing.
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
- Establish and initialize a Matrix AsyncClient connected to the configured homeserver, with optional End-to-End Encryption (E2EE) support.
537
-
538
- This function will:
539
- - Prefer credentials.json (E2EE-enabled session) when present; otherwise use the "matrix" section in the provided global configuration.
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 instead of the module-level config. If provided, it replaces the global config for this connection attempt.
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 instance ready for use, or None when connection cannot be established due to missing credentials/configuration.
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 reports a sync error.
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.error(f"Error during automatic login: {type(e).__name__}")
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 = matrix_section["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
- # Check both 'encryption' and 'e2ee' keys for backward compatibility
717
- matrix_cfg = config.get("matrix", {}) or {}
718
- encryption_enabled = matrix_cfg.get("encryption", {}).get("enabled", False)
719
- e2ee_enabled = matrix_cfg.get("e2ee", {}).get("enabled", False)
720
- if encryption_enabled or e2ee_enabled:
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
- import olm # noqa: F401
737
-
738
- # Also check for other required E2EE dependencies
739
- try:
740
- from nio.crypto import OlmDevice # noqa: F401
741
- from nio.store import SqliteStore # noqa: F401
742
-
743
- logger.debug("All E2EE dependencies are available")
744
- except ImportError as e:
745
- logger.error(f"Missing E2EE dependency: {e}")
746
- logger.error(
747
- "Please reinstall with: pipx install 'mmrelay[e2e]'"
748
- )
749
- raise RuntimeError(
750
- "Missing E2EE dependency (Olm/SqliteStore)"
751
- ) from e
752
-
753
- e2ee_enabled = True
754
- logger.info("End-to-End Encryption (E2EE) is enabled")
755
-
756
- # Get store path from config or use default
757
- if (
758
- "encryption" in config["matrix"]
759
- and "store_path" in config["matrix"]["encryption"]
760
- ):
761
- e2ee_store_path = os.path.expanduser(
762
- config["matrix"]["encryption"]["store_path"]
763
- )
764
- elif (
765
- "e2ee" in config["matrix"]
766
- and "store_path" in config["matrix"]["e2ee"]
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
- from mmrelay.config import get_e2ee_store_dir
1029
+ logger.debug(
1030
+ "Skipping additional E2EE dependency imports in test mode"
1031
+ )
773
1032
 
774
- e2ee_store_path = get_e2ee_store_dir()
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
- # Create store directory if it doesn't exist
777
- os.makedirs(e2ee_store_path, exist_ok=True)
1055
+ # Create store directory if it doesn't exist
1056
+ os.makedirs(e2ee_store_path, exist_ok=True)
778
1057
 
779
- # Check if store directory contains database files
780
- store_files = (
781
- os.listdir(e2ee_store_path)
782
- if os.path.exists(e2ee_store_path)
783
- else []
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
- logger.debug(f"Using E2EE store path: {e2ee_store_path}")
1074
+ logger.debug(f"Using E2EE store path: {e2ee_store_path}")
796
1075
 
797
- # If device_id is not present in credentials, we can attempt to learn it later.
798
- if not e2ee_device_id:
799
- logger.debug(
800
- "No device_id in credentials; will retrieve from store/whoami later if available"
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 as e:
875
- logger.error(f"Failed to upload E2EE keys: {e}")
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
- logger.error(f"Initial sync failed: {sync_response}")
893
- raise ConnectionError(f"Matrix sync failed: {sync_response}")
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.error(
1264
+ logger.exception(
933
1265
  f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
934
1266
  )
935
- raise
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, enable end-to-end encryption, and persist credentials for later use.
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
- # Enable nio debug logging for detailed connection analysis
983
- logging.getLogger("nio").setLevel(logging.DEBUG)
984
- logging.getLogger("nio.client").setLevel(logging.DEBUG)
985
- logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
986
- logging.getLogger("aiohttp").setLevel(logging.DEBUG)
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
- if isinstance(discovery_response, DiscoveryInfoResponse):
1016
- actual_homeserver = discovery_response.homeserver_url
1017
- logger.info(f"Server discovery successful: {actual_homeserver}")
1018
- homeserver = actual_homeserver
1019
- elif isinstance(discovery_response, DiscoveryInfoError):
1020
- logger.info(
1021
- f"Server discovery failed, using original URL: {homeserver}"
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
- if not username.startswith("@"):
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
- # Get the E2EE store path
1084
- store_path = get_e2ee_store_dir()
1085
- os.makedirs(store_path, exist_ok=True)
1086
- logger.debug(f"Using E2EE store path: {store_path}")
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
- # Create client config for E2EE
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=True
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 the original working device name
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.error(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
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
- logger.error(f"Login exception: {e}")
1131
- logger.error(f"Exception type: {type(e)}")
1132
- if hasattr(e, "message"):
1133
- logger.error(f"Exception message: {e.message}")
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
- if hasattr(response, "access_token"):
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": username,
1697
+ "user_id": actual_user_id,
1144
1698
  "access_token": response.access_token,
1145
- "device_id": response.device_id,
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
- # Better error logging
1163
- logger.error(f"Login failed: {response}")
1164
- if hasattr(response, "message"):
1165
- logger.error(f"Error message: {response.message}")
1166
- if hasattr(response, "status_code"):
1167
- logger.error(f"Status code: {response.status_code}")
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 as e:
1172
- logger.error(f"Error during login: {e}")
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, resolving aliases and updating the local matrix_rooms mapping.
1778
+ Join the bot to a Matrix room by ID or alias.
1179
1779
 
1180
- If given a room alias (starts with '#'), the alias is resolved to a room ID and any entry in the global matrix_rooms list that referenced that alias will be replaced with the resolved room ID. If the bot is not already in the resolved room (or provided room ID), the function attempts to join it. Successes and failures are logged; exceptions are caught and handled internally (the function does not raise).
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): Room ID (e.g. "!abcdef:server") or alias (e.g. "#room:server") to join.
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
- try:
1186
- if room_id_or_alias.startswith("#"):
1187
- # If it's a room alias, resolve it to a room ID
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
- if not hasattr(response, "room_id") or not response.room_id:
1190
- logger.error(
1191
- f"Failed to resolve room alias '{room_id_or_alias}': {getattr(response, 'message', str(response))}"
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
- # Attempt to join the room if not already joined
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
- if response and hasattr(response, "room_id"):
1207
- logger.info(f"Joined room '{room_id_or_alias}' successfully")
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
- f"Failed to join room '{room_id_or_alias}': {getattr(response, 'message', str(response))}"
1851
+ "Failed to join room '%s': %s",
1852
+ room_id,
1853
+ getattr(response, "message", str(response)),
1211
1854
  )
1212
1855
  else:
1213
- logger.debug(f"Bot is already in room '{room_id_or_alias}'")
1214
- except Exception as e:
1215
- logger.error(f"Error joining room '{room_id_or_alias}': {e}")
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 specific error message for why E2EE is not properly enabled.
1221
- Uses the unified E2EE status system for consistent messaging.
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, optionally as an emote, emoji-marked message, or as a reply, and record a Meshtastic↔Matrix mapping when configured.
1907
+ Relay a Meshtastic message into a Matrix room and optionally record a Meshtastic↔Matrix mapping.
1249
1908
 
1250
- Builds a Matrix message payload (plain and HTML/markdown-safe formatted bodies), applies Matrix reply framing when reply_to_event_id is provided, enforces E2EE restrictions (will block sending to encrypted rooms when client E2EE is not enabled), sends the event via the global Matrix client, and if message-interactions are enabled and a Meshtastic message ID is provided stores a mapping for future cross-network replies/reactions. Handles timeouts and errors by logging and returning without raising.
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 givenpersists 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
- room_id (str): Matrix room ID or alias to send to.
1254
- message (str): Message text to relay; may contain Markdown or HTML which will be converted/stripped as needed.
1255
- longname (str): Sender long display name from Meshtastic used for attribution in formatted output.
1256
- shortname (str): Sender short display name from Meshtastic.
1257
- meshnet_name (str): Originating meshnet name (used for metadata/attribution).
1258
- portnum (int): Meshtastic port number the message originated from.
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
- - Sends a message to Matrix using the global matrix client.
1268
- - May persist a Meshtastic↔Matrix mapping for replies/reactions when storage is enabled.
1269
- - Logs errors and warnings; does not raise on send failures or storage errors (errors are caught and logged).
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
- None
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 to HTML if needed (like base plugin does)
1970
+ # Process markdown/HTML if available; otherwise, safe fallback
1318
1971
  if has_markdown or has_html:
1319
- raw_html = markdown.markdown(message)
1320
-
1321
- # Sanitize HTML to prevent injection attacks
1322
- formatted_body = bleach.clean(
1323
- raw_html,
1324
- tags=[
1325
- "b",
1326
- "strong",
1327
- "i",
1328
- "em",
1329
- "code",
1330
- "pre",
1331
- "br",
1332
- "blockquote",
1333
- "a",
1334
- "ul",
1335
- "ol",
1336
- "li",
1337
- "p",
1338
- ],
1339
- attributes={"a": ["href"]},
1340
- strip=True,
1341
- )
1342
-
1343
- plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
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"> <@{bot_user_id}> [{safe_sender_display}]: {safe_original}"
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/#/@{bot_user_id}"
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}">@{bot_user_id}</a><br>'
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 as e:
1493
- logger.error(f"Error sending message to Matrix room {room_id}: {e}")
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
- # Store the message map
1506
- store_message_map(
1507
- meshtastic_id,
1508
- response.event_id,
1509
- room_id,
1510
- meshtastic_text if meshtastic_text else message,
1511
- meshtastic_meshnet=local_meshnet_name,
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(msgs_to_keep)
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 as e:
1524
- logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
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 the given text to fit within the specified byte size.
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
- :param text: The text to truncate.
1532
- :param max_bytes: The maximum allowed byte size for the truncated text.
1533
- :return: The truncated text.
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
- Retrieve the display name of a Matrix user, preferring the room-specific name if available.
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: The user's display name, or their Matrix ID if no display name is set.
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(config, full_display_name, text):
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
- prefix = get_meshtastic_prefix(config, full_display_name)
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
- # Strip quoted content from the reply text
1581
- clean_text = strip_quoted_lines(text)
1582
- reply_message = f"{prefix}{clean_text}"
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
- Queue a Matrix-origin reply for transmission over Meshtastic, optionally as a structured reply targeting a specific Meshtastic message.
1599
-
1600
- If Meshtastic broadcasting is disabled in configuration, the function does nothing. When broadcasting is enabled, it enqueues either a structured reply (if reply_id is provided and supported) or a regular text broadcast. If storage_enabled is True, a message-mapping metadata record is created so the Meshtastic message can be correlated back to the originating Matrix event for future replies/reactions; the mapping retention uses the configured msgs_to_keep value.
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
- reply_message (str): Message text already formatted for Meshtastic.
1604
- full_display_name (str): Human-readable sender name to include in message descriptions.
1605
- room_config (dict): Room-specific configuration; must contain "meshtastic_channel".
1606
- room: Matrix room object where the original event occurred (used for event and room IDs).
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.
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
- meshtastic_interface = connect_meshtastic()
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 as e:
1703
- meshtastic_logger.error(f"Error sending Matrix reply to Meshtastic: {e}")
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
- Relays a Matrix reply to the corresponding Meshtastic message if a mapping exists.
1718
-
1719
- Looks up the original Meshtastic message using the Matrix event ID being replied to. If found, formats and sends the reply to Meshtastic, preserving conversational context. Returns True if the reply was successfully handled; otherwise, returns False to allow normal message processing.
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 relayed to Meshtastic, False otherwise.
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
- orig = get_message_map_by_matrix_event_id(reply_to_event_id)
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(config, full_display_name, text)
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 as e:
1793
- logger.error(f"Failed to request keys for event {event.event_id}: {e}")
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 appropriate content to Meshtastic.
2548
+ Handle an incoming Matrix room event and, when applicable, relay it to Meshtastic.
1808
2549
 
1809
- Processes inbound Matrix events (text, notice, emote, reaction, encrypted events, and reply structures) for supported rooms and, depending on configuration, forwards messages, reactions, and replies to the Meshtastic network. Behavior summary:
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
- - Logs and notes room encryption changes; encrypted message decryption is handled elsewhere.
1812
- - Uses per-room configuration to decide whether to process the event; unsupported rooms are ignored.
1813
- - Honors interaction settings (reactions and replies) and a broadcast_enabled gate for whether Matrix->Meshtastic forwarding occurs.
1814
- - For reactions: looks up mapped Meshtastic messages and forwards reactions back to the originating mesh when configured; supports special handling for remote-meshnet reactions and emote-derived reactions.
1815
- - For replies: attempts to find the corresponding Meshtastic message mapping and queue a reply to Meshtastic when enabled.
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 messages or data to be sent via Meshtastic (via the internal queue system).
1821
- - May read and consult persistent message mapping storage to support reaction and reply bridging.
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
- for room_conf in matrix_rooms:
1858
- if room_conf["id"] == room.room_id:
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
- meshtastic_interface = connect_meshtastic()
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
- meshtastic_interface = connect_meshtastic()
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
- meshtastic_interface = connect_meshtastic()
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: