mmrelay 1.2.1__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/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,30 @@ 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.
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
- 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).
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 instead of the module-level config. If provided, it replaces the global config for this connection attempt.
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 instance ready for use, or None when connection cannot be established due to missing credentials/configuration.
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 reports a sync error.
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.error(f"Error during automatic login: {type(e).__name__}")
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 = matrix_section["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
- # 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:
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
- 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]'"
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
- 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
- )
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
- e2ee_store_path = get_e2ee_store_dir()
1055
+ e2ee_store_path = get_e2ee_store_dir()
775
1056
 
776
- # Create store directory if it doesn't exist
777
- os.makedirs(e2ee_store_path, exist_ok=True)
1057
+ # Create store directory if it doesn't exist
1058
+ os.makedirs(e2ee_store_path, exist_ok=True)
778
1059
 
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."
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
- logger.debug(f"Using E2EE store path: {e2ee_store_path}")
1076
+ logger.debug(f"Using E2EE store path: {e2ee_store_path}")
796
1077
 
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
- )
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 as e:
875
- logger.error(f"Failed to upload E2EE keys: {e}")
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
- logger.error(f"Initial sync failed: {sync_response}")
893
- raise ConnectionError(f"Matrix sync failed: {sync_response}")
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.error(
1266
+ logger.exception(
933
1267
  f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
934
1268
  )
935
- raise
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, enable end-to-end encryption, and persist credentials for later use.
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
- # 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)
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
- 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}"
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
- 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}"
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
- # 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}")
1478
+ # Check if E2EE is enabled in configuration
1479
+ from mmrelay.config import is_e2ee_enabled, load_config
1087
1480
 
1088
- # Create client config for E2EE
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=True
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 the original working device name
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.error(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
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
- 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}")
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
- if hasattr(response, "access_token"):
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": username,
1699
+ "user_id": actual_user_id,
1144
1700
  "access_token": response.access_token,
1145
- "device_id": response.device_id,
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
- # 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}")
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 as e:
1172
- logger.error(f"Error during login: {e}")
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, resolving aliases and updating the local matrix_rooms mapping.
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
- 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).
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
- Parameters:
1183
- room_id_or_alias (str): Room ID (e.g. "!abcdef:server") or alias (e.g. "#room:server") to join.
1184
- """
1185
- try:
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
- 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))}"
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
- # Attempt to join the room if not already joined
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
- if response and hasattr(response, "room_id"):
1207
- logger.info(f"Joined room '{room_id_or_alias}' successfully")
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
- f"Failed to join room '{room_id_or_alias}': {getattr(response, 'message', str(response))}"
1853
+ "Failed to join room '%s': %s",
1854
+ room_id,
1855
+ getattr(response, "message", str(response)),
1211
1856
  )
1212
1857
  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}")
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 specific error message for why E2EE is not properly enabled.
1221
- Uses the unified E2EE status system for consistent messaging.
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, optionally as an emote, emoji-marked message, or as a reply, and record a Meshtastic↔Matrix mapping when configured.
1909
+ Relay a Meshtastic message into a Matrix room and optionally record a Meshtastic↔Matrix mapping.
1249
1910
 
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.
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 givenpersists 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
- 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.
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
- - 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).
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
- None
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 to HTML if needed (like base plugin does)
1972
+ # Process markdown/HTML if available; otherwise, safe fallback
1318
1973
  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)
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"> <@{bot_user_id}> [{safe_sender_display}]: {safe_original}"
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/#/@{bot_user_id}"
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}">@{bot_user_id}</a><br>'
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 as e:
1493
- logger.error(f"Error sending message to Matrix room {room_id}: {e}")
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
- # 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,
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(msgs_to_keep)
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 as e:
1524
- logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
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 the given text to fit within the specified byte size.
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
- :param text: The text to truncate.
1532
- :param max_bytes: The maximum allowed byte size for the truncated text.
1533
- :return: The truncated text.
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
- Retrieve the display name of a Matrix user, preferring the room-specific name if available.
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: The user's display name, or their Matrix ID if no display name is set.
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(config, full_display_name, text):
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
- prefix = get_meshtastic_prefix(config, full_display_name)
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
- # Strip quoted content from the reply text
1581
- clean_text = strip_quoted_lines(text)
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
- 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
-
1602
- 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.
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
- 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
+ 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 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,23 +2545,21 @@ 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.
1808
-
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:
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
- - 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.
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 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.
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
- 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: