mmrelay 1.1.4__py3-none-any.whl → 1.2.1__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,30 +1,72 @@
1
1
  import asyncio
2
+ import getpass
3
+ import html
2
4
  import io
5
+ import json
6
+ import logging
7
+ import os
3
8
  import re
4
- import ssl
9
+ import sys
5
10
  import time
6
- from typing import Union
11
+ from typing import Any, Dict, Union
12
+ from urllib.parse import urlparse
7
13
 
8
- import certifi
14
+ import bleach
15
+ import markdown
9
16
  import meshtastic.protobuf.portnums_pb2
10
17
  from nio import (
11
18
  AsyncClient,
12
19
  AsyncClientConfig,
20
+ DiscoveryInfoError,
21
+ DiscoveryInfoResponse,
13
22
  MatrixRoom,
23
+ MegolmEvent,
14
24
  ReactionEvent,
15
25
  RoomMessageEmote,
16
26
  RoomMessageNotice,
17
27
  RoomMessageText,
18
28
  UploadResponse,
19
- WhoamiError,
20
29
  )
21
30
  from nio.events.room_events import RoomMemberEvent
22
31
  from PIL import Image
23
32
 
33
+ # Import nio exception types with error handling for test environments
34
+ try:
35
+ from nio.exceptions import LocalProtocolError as NioLocalProtocolError
36
+ from nio.exceptions import LocalTransportError as NioLocalTransportError
37
+ from nio.exceptions import RemoteProtocolError as NioRemoteProtocolError
38
+ from nio.exceptions import RemoteTransportError as NioRemoteTransportError
39
+ from nio.responses import ErrorResponse as NioErrorResponse
40
+ from nio.responses import LoginError as NioLoginError
41
+ from nio.responses import LogoutError as NioLogoutError
42
+ except ImportError:
43
+ # Fallback for test environments where nio imports might fail
44
+ NioLoginError = Exception
45
+ NioLogoutError = Exception
46
+ NioErrorResponse = Exception
47
+ NioLocalProtocolError = Exception
48
+ NioRemoteProtocolError = Exception
49
+ NioLocalTransportError = Exception
50
+ NioRemoteTransportError = Exception
51
+
52
+ from mmrelay.cli_utils import (
53
+ _create_ssl_context,
54
+ msg_require_auth_login,
55
+ msg_retry_auth_login,
56
+ )
57
+ from mmrelay.config import (
58
+ get_base_dir,
59
+ get_e2ee_store_dir,
60
+ get_meshtastic_config_value,
61
+ load_credentials,
62
+ save_credentials,
63
+ )
64
+ from mmrelay.constants.app import WINDOWS_PLATFORM
24
65
  from mmrelay.constants.config import (
25
- CONFIG_KEY_ACCESS_TOKEN,
26
- CONFIG_KEY_HOMESERVER,
27
66
  CONFIG_SECTION_MATRIX,
67
+ DEFAULT_BROADCAST_ENABLED,
68
+ DEFAULT_DETECTION_SENSOR,
69
+ E2EE_KEY_SHARING_DELAY_SECONDS,
28
70
  )
29
71
  from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
30
72
  from mmrelay.constants.formats import (
@@ -32,7 +74,22 @@ from mmrelay.constants.formats import (
32
74
  DEFAULT_MESHTASTIC_PREFIX,
33
75
  DETECTION_SENSOR_APP,
34
76
  )
35
- from mmrelay.constants.network import MILLISECONDS_PER_SECOND
77
+ from mmrelay.constants.messages import (
78
+ DEFAULT_MESSAGE_TRUNCATE_BYTES,
79
+ DISPLAY_NAME_DEFAULT_LENGTH,
80
+ MAX_TRUNCATION_LENGTH,
81
+ MESHNET_NAME_ABBREVIATION_LENGTH,
82
+ MESSAGE_PREVIEW_LENGTH,
83
+ SHORTNAME_FALLBACK_LENGTH,
84
+ TRUNCATION_LOG_LIMIT,
85
+ )
86
+ from mmrelay.constants.network import (
87
+ MATRIX_EARLY_SYNC_TIMEOUT,
88
+ MATRIX_LOGIN_TIMEOUT,
89
+ MATRIX_ROOM_SEND_TIMEOUT,
90
+ MATRIX_SYNC_OPERATION_TIMEOUT,
91
+ MILLISECONDS_PER_SECOND,
92
+ )
36
93
  from mmrelay.db_utils import (
37
94
  get_message_map_by_matrix_event_id,
38
95
  prune_message_map,
@@ -44,15 +101,127 @@ from mmrelay.log_utils import get_logger
44
101
  from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
45
102
  from mmrelay.message_queue import get_message_queue, queue_message
46
103
 
47
- logger = get_logger(name="matrix_utils")
104
+ logger = get_logger(name="Matrix")
105
+
106
+
107
+ def _display_room_channel_mappings(
108
+ rooms: Dict[str, Any], config: Dict[str, Any], e2ee_status: Dict[str, Any]
109
+ ) -> None:
110
+ """
111
+ Log Matrix rooms grouped by Meshtastic channel, showing mapping counts and E2EE/encryption indicators.
112
+
113
+ Reads the "matrix_rooms" entry from config (accepting either dict or list form), builds a mapping from room ID to the configured "meshtastic_channel", then groups and logs rooms ordered by channel number. For each room logs an emoji/status depending on the room's encryption flag and the provided e2ee_status["overall_status"] (common values: "ready", "unavailable", "disabled").
114
+
115
+ Parameters:
116
+ rooms (dict): Mapping of room_id -> room object (room objects should expose at least `display_name` and `encrypted` attributes or fall back to the room_id).
117
+ config (dict): Configuration dict containing a "matrix_rooms" section; entries should include "id" and "meshtastic_channel" when using dict/list room formats.
118
+ e2ee_status (dict): E2EE status information; function expects an "overall_status" key used to determine messaging/encryption indicators.
119
+
120
+ Returns:
121
+ None
122
+ """
123
+ if not rooms:
124
+ logger.info("Bot is not in any Matrix rooms")
125
+ return
126
+
127
+ # Get matrix_rooms configuration
128
+ matrix_rooms_config = config.get("matrix_rooms", [])
129
+ if not matrix_rooms_config:
130
+ logger.info("No matrix_rooms configuration found")
131
+ return
132
+
133
+ # Normalize matrix_rooms configuration to list format
134
+ if isinstance(matrix_rooms_config, dict):
135
+ # Convert dict format to list format
136
+ matrix_rooms_list = list(matrix_rooms_config.values())
137
+ else:
138
+ # Already in list format
139
+ matrix_rooms_list = matrix_rooms_config
140
+
141
+ # Create mapping of room_id -> channel number
142
+ room_to_channel = {}
143
+ for room_config in matrix_rooms_list:
144
+ if isinstance(room_config, dict):
145
+ room_id = room_config.get("id")
146
+ channel = room_config.get("meshtastic_channel")
147
+ if room_id and channel is not None:
148
+ room_to_channel[room_id] = channel
149
+
150
+ # Group rooms by channel
151
+ channels = {}
152
+
153
+ for room_id, room in rooms.items():
154
+ if room_id in room_to_channel:
155
+ channel = room_to_channel[room_id]
156
+ if channel not in channels:
157
+ channels[channel] = []
158
+ channels[channel].append((room_id, room))
159
+
160
+ # Display header
161
+ mapped_rooms = sum(len(room_list) for room_list in channels.values())
162
+ logger.info(f"Matrix Rooms → Meshtastic Channels ({mapped_rooms} configured):")
163
+
164
+ # Display rooms organized by channel (sorted by channel number)
165
+ for channel in sorted(channels.keys()):
166
+ room_list = channels[channel]
167
+ logger.info(f" Channel {channel}:")
168
+
169
+ for room_id, room in room_list:
170
+ room_name = getattr(room, "display_name", room_id)
171
+ encrypted = getattr(room, "encrypted", False)
172
+
173
+ # Format with encryption status
174
+ if e2ee_status["overall_status"] == "ready":
175
+ if encrypted:
176
+ logger.info(f" 🔒 {room_name}")
177
+ else:
178
+ logger.info(f" ✅ {room_name}")
179
+ else:
180
+ if encrypted:
181
+ if e2ee_status["overall_status"] == "unavailable":
182
+ logger.info(
183
+ f" ⚠️ {room_name} (E2EE not supported - messages blocked)"
184
+ )
185
+ elif e2ee_status["overall_status"] == "disabled":
186
+ logger.info(
187
+ f" ⚠️ {room_name} (E2EE disabled - messages blocked)"
188
+ )
189
+ else:
190
+ logger.info(
191
+ f" ⚠️ {room_name} (E2EE incomplete - messages may be blocked)"
192
+ )
193
+ else:
194
+ logger.info(f" ✅ {room_name}")
195
+
196
+
197
+ def _can_auto_create_credentials(matrix_config: dict) -> bool:
198
+ """
199
+ Return True if the Matrix config provides non-empty strings for homeserver, a user id (bot_user_id or user_id), and password.
200
+
201
+ Checks that the `matrix_config` contains the required fields to perform an automatic login flow by ensuring each value exists and is a non-blank string.
202
+
203
+ Parameters:
204
+ matrix_config (dict): The `matrix` section from config.yaml.
205
+
206
+ Returns:
207
+ bool: True when homeserver, (bot_user_id or user_id), and password are all present and non-empty strings; otherwise False.
208
+ """
209
+ homeserver = matrix_config.get("homeserver")
210
+ user = matrix_config.get("bot_user_id") or matrix_config.get("user_id")
211
+ password = matrix_config.get("password")
212
+ return all(isinstance(v, str) and v.strip() for v in (homeserver, user, password))
48
213
 
49
214
 
50
215
  def _get_msgs_to_keep_config():
51
216
  """
52
- Returns the configured number of messages to retain for message mapping, supporting both current and legacy configuration sections.
217
+ Return the configured number of Meshtastic–Matrix message mappings to retain.
218
+
219
+ Reads the global `config` and prefers the new location `database.msg_map.msgs_to_keep`.
220
+ If that section is absent, falls back to the legacy `db.msg_map.msgs_to_keep` and emits a deprecation warning.
221
+ If no configuration is available or `msgs_to_keep` is not set, returns DEFAULT_MSGS_TO_KEEP.
53
222
 
54
223
  Returns:
55
- int: Number of messages to keep for message mapping; defaults to the predefined constant if not set.
224
+ int: Number of message mappings to keep.
56
225
  """
57
226
  global config
58
227
  if not config:
@@ -150,10 +319,12 @@ def _add_truncated_vars(format_vars, prefix, text):
150
319
  # Always add truncated variables, even for empty text (to prevent KeyError)
151
320
  text = text or "" # Convert None to empty string
152
321
  logger.debug(f"Adding truncated vars for prefix='{prefix}', text='{text}'")
153
- for i in range(1, 21): # Support up to 20 chars, always add all variants
322
+ for i in range(
323
+ 1, MAX_TRUNCATION_LENGTH + 1
324
+ ): # Support up to MAX_TRUNCATION_LENGTH chars, always add all variants
154
325
  truncated_value = text[:i]
155
326
  format_vars[f"{prefix}{i}"] = truncated_value
156
- if i <= 6: # Only log first few to avoid spam
327
+ if i <= TRUNCATION_LOG_LIMIT: # Only log first few to avoid spam
157
328
  logger.debug(f" {prefix}{i} = '{truncated_value}'")
158
329
 
159
330
 
@@ -238,7 +409,7 @@ def get_meshtastic_prefix(config, display_name, user_id=None):
238
409
  )
239
410
  # The default format only uses 'display5', which is safe to format
240
411
  return DEFAULT_MESHTASTIC_PREFIX.format(
241
- display5=display_name[:5] if display_name else ""
412
+ display5=display_name[:DISPLAY_NAME_DEFAULT_LENGTH] if display_name else ""
242
413
  )
243
414
 
244
415
 
@@ -321,7 +492,6 @@ bot_start_time = int(
321
492
  time.time() * MILLISECONDS_PER_SECOND
322
493
  ) # Timestamp when the bot starts, used to filter out old messages
323
494
 
324
- logger = get_logger(name="Matrix")
325
495
 
326
496
  matrix_client = None
327
497
 
@@ -363,9 +533,26 @@ def bot_command(command, event):
363
533
 
364
534
  async def connect_matrix(passed_config=None):
365
535
  """
366
- Asynchronously connects to the Matrix homeserver, initializes the Matrix client, and retrieves the bot's device ID and display name.
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
+
546
+ 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
+
549
+ Returns:
550
+ AsyncClient: An initialized matrix-nio AsyncClient instance ready for use, or None when connection cannot be established due to missing credentials/configuration.
367
551
 
368
- If a configuration dictionary is provided, it updates the global configuration before connecting. Returns the initialized Matrix AsyncClient instance, or `None` if configuration is missing. Raises `ConnectionError` if SSL context creation fails.
552
+ 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.
369
556
  """
370
557
  global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
371
558
 
@@ -378,47 +565,388 @@ async def connect_matrix(passed_config=None):
378
565
  logger.error("No configuration available. Cannot connect to Matrix.")
379
566
  return None
380
567
 
381
- # Extract Matrix configuration
382
- matrix_homeserver = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_HOMESERVER]
383
- matrix_rooms = config["matrix_rooms"]
384
- matrix_access_token = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_ACCESS_TOKEN]
385
- bot_user_id = config["matrix"]["bot_user_id"]
386
-
387
568
  # Check if client already exists
388
569
  if matrix_client:
389
570
  return matrix_client
390
571
 
391
- # Create SSL context using certifi's certificates
572
+ # Check for credentials.json first
573
+ credentials = None
574
+ credentials_path = None
575
+
576
+ # Try to find credentials.json in the config directory
392
577
  try:
393
- ssl_context = ssl.create_default_context(cafile=certifi.where())
578
+ from mmrelay.config import get_base_dir
579
+
580
+ config_dir = get_base_dir()
581
+ credentials_path = os.path.join(config_dir, "credentials.json")
582
+
583
+ if os.path.exists(credentials_path):
584
+ with open(credentials_path, "r") as f:
585
+ credentials = json.load(f)
394
586
  except Exception as e:
395
- logger.error(f"Failed to create SSL context: {e}")
396
- raise ConnectionError(f"SSL context creation failed: {e}") from e
587
+ logger.warning(f"Error loading credentials: {e}")
588
+
589
+ # If credentials.json exists, use it
590
+ if credentials:
591
+ matrix_homeserver = credentials["homeserver"]
592
+ matrix_access_token = credentials["access_token"]
593
+ bot_user_id = credentials["user_id"]
594
+ e2ee_device_id = credentials.get("device_id")
595
+
596
+ # Log consolidated credentials info
597
+ logger.debug(f"Using Matrix credentials (device: {e2ee_device_id})")
598
+
599
+ # If device_id is missing, warn but proceed; we'll learn and persist it after restore_login().
600
+ if not isinstance(e2ee_device_id, str) or not e2ee_device_id.strip():
601
+ logger.warning(
602
+ "credentials.json has no valid device_id; proceeding to restore session and discover device_id."
603
+ )
604
+ e2ee_device_id = None
605
+
606
+ # If config also has Matrix login info, let the user know we're ignoring it
607
+ if config and "matrix" in config and "access_token" in config["matrix"]:
608
+ logger.info(
609
+ "NOTE: Ignoring Matrix login details in config.yaml in favor of credentials.json"
610
+ )
611
+ # Check if we can automatically create credentials from config.yaml
612
+ elif (
613
+ config and "matrix" in config and _can_auto_create_credentials(config["matrix"])
614
+ ):
615
+ logger.info(
616
+ "No credentials.json found, but config.yaml has password field. Attempting automatic login..."
617
+ )
618
+
619
+ matrix_section = config["matrix"]
620
+ homeserver = matrix_section["homeserver"]
621
+ username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
622
+ password = matrix_section["password"]
623
+
624
+ # Attempt automatic login
625
+ try:
626
+ success = await login_matrix_bot(
627
+ homeserver=homeserver,
628
+ username=username,
629
+ password=password,
630
+ logout_others=False,
631
+ )
632
+
633
+ if success:
634
+ logger.info(
635
+ "Automatic login successful! Credentials saved to credentials.json"
636
+ )
637
+ # Load the newly created credentials and set up for credentials flow
638
+ credentials = load_credentials()
639
+ if not credentials:
640
+ logger.error("Failed to load newly created credentials")
641
+ return None
642
+
643
+ # Set up variables for credentials-based connection
644
+ matrix_homeserver = credentials["homeserver"]
645
+ matrix_access_token = credentials["access_token"]
646
+ bot_user_id = credentials["user_id"]
647
+ e2ee_device_id = credentials.get("device_id")
648
+ else:
649
+ logger.error(
650
+ "Automatic login failed. Please check your credentials or use 'mmrelay auth login'"
651
+ )
652
+ return None
653
+ except Exception as e:
654
+ logger.error(f"Error during automatic login: {type(e).__name__}")
655
+ logger.error("Please use 'mmrelay auth login' for interactive setup")
656
+ return None
657
+ else:
658
+ # Check if config is available
659
+ if config is None:
660
+ logger.error("No configuration available. Cannot connect to Matrix.")
661
+ return None
662
+
663
+ # Check if matrix section exists in config
664
+ if "matrix" not in config:
665
+ logger.error(
666
+ "No Matrix authentication available. Neither credentials.json nor matrix section in config found."
667
+ )
668
+ logger.error(msg_require_auth_login())
669
+ return None
670
+
671
+ matrix_section = config["matrix"]
672
+
673
+ # Check for required fields in matrix section
674
+ required_fields = ["homeserver", "access_token", "bot_user_id"]
675
+ missing_fields = [
676
+ field for field in required_fields if field not in matrix_section
677
+ ]
678
+
679
+ if missing_fields:
680
+ logger.error(f"Matrix section is missing required fields: {missing_fields}")
681
+ logger.error(msg_require_auth_login())
682
+ return None
683
+
684
+ # Extract Matrix configuration from config
685
+ matrix_homeserver = matrix_section["homeserver"]
686
+ matrix_access_token = matrix_section["access_token"]
687
+ bot_user_id = matrix_section["bot_user_id"]
688
+
689
+ # Manual method does not support device_id - use auth system for E2EE
690
+ e2ee_device_id = None
691
+
692
+ # Get matrix rooms from config
693
+ if "matrix_rooms" not in config:
694
+ logger.error("Configuration is missing 'matrix_rooms' section")
695
+ logger.error(
696
+ "Please ensure your config.yaml includes matrix_rooms configuration"
697
+ )
698
+ raise ValueError("Missing required 'matrix_rooms' configuration")
699
+ matrix_rooms = config["matrix_rooms"]
700
+
701
+ # Create SSL context using certifi's certificates with system default fallback
702
+ ssl_context = _create_ssl_context()
703
+ if ssl_context is None:
704
+ logger.warning(
705
+ "Failed to create certifi/system SSL context; proceeding with AsyncClient defaults"
706
+ )
707
+
708
+ # Check if E2EE is enabled
709
+ e2ee_enabled = False
710
+ e2ee_store_path = None
711
+ # Only initialize e2ee_device_id if not already set from credentials
712
+ if "e2ee_device_id" not in locals():
713
+ e2ee_device_id = None
714
+
715
+ 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:
721
+ # Check if running on Windows
722
+ if sys.platform == WINDOWS_PLATFORM:
723
+ logger.error(
724
+ "E2EE is not supported on Windows due to library limitations."
725
+ )
726
+ logger.error(
727
+ "The python-olm library requires native C libraries that are difficult to install on Windows."
728
+ )
729
+ logger.error(
730
+ "Please disable E2EE in your configuration or use a Linux/macOS system for E2EE support."
731
+ )
732
+ e2ee_enabled = False
733
+ else:
734
+ # Check if python-olm is installed
735
+ 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
+ )
771
+ else:
772
+ from mmrelay.config import get_e2ee_store_dir
773
+
774
+ e2ee_store_path = get_e2ee_store_dir()
775
+
776
+ # Create store directory if it doesn't exist
777
+ os.makedirs(e2ee_store_path, exist_ok=True)
778
+
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."
793
+ )
794
+
795
+ logger.debug(f"Using E2EE store path: {e2ee_store_path}")
796
+
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
+ )
802
+ except ImportError:
803
+ logger.warning(
804
+ "E2EE is enabled in config but python-olm is not installed."
805
+ )
806
+ logger.warning("Install 'mmrelay[e2e]' to use E2EE features.")
807
+ e2ee_enabled = False
808
+ except (KeyError, TypeError):
809
+ # E2EE not configured
810
+ pass
397
811
 
398
812
  # Initialize the Matrix client with custom SSL context
399
- client_config = AsyncClientConfig(encryption_enabled=False)
813
+ # Use the same AsyncClientConfig pattern as working E2EE examples
814
+ client_config = AsyncClientConfig(
815
+ max_limit_exceeded=0,
816
+ max_timeouts=0,
817
+ store_sync_tokens=True,
818
+ encryption_enabled=e2ee_enabled,
819
+ )
820
+
821
+ # Log the device ID being used
822
+ if e2ee_device_id:
823
+ logger.debug(f"Device ID from credentials: {e2ee_device_id}")
824
+
400
825
  matrix_client = AsyncClient(
401
826
  homeserver=matrix_homeserver,
402
827
  user=bot_user_id,
828
+ device_id=e2ee_device_id, # Will be None if not specified in config or credentials
829
+ store_path=e2ee_store_path if e2ee_enabled else None,
403
830
  config=client_config,
404
831
  ssl=ssl_context,
405
832
  )
406
833
 
407
- # Set the access_token and user_id
408
- matrix_client.access_token = matrix_access_token
409
- matrix_client.user_id = bot_user_id
834
+ # Set the access_token and user_id using restore_login for better session management
835
+ if credentials:
836
+ # Use restore_login method for proper session restoration.
837
+ # nio will handle loading the store automatically if store_path was provided
838
+ # to the client constructor.
839
+ matrix_client.restore_login(
840
+ user_id=bot_user_id,
841
+ device_id=e2ee_device_id,
842
+ access_token=matrix_access_token,
843
+ )
844
+ logger.info(
845
+ f"Restored login session for {bot_user_id} with device {e2ee_device_id}"
846
+ )
410
847
 
411
- # Attempt to retrieve the device_id using whoami()
412
- whoami_response = await matrix_client.whoami()
413
- if isinstance(whoami_response, WhoamiError):
414
- logger.error(f"Failed to retrieve device_id: {whoami_response.message}")
415
- matrix_client.device_id = None
848
+ # If the device_id was not known up-front, capture what nio has after restore.
849
+ if not e2ee_device_id and getattr(matrix_client, "device_id", None):
850
+ e2ee_device_id = matrix_client.device_id
851
+ logger.debug(f"Device ID established after restore_login: {e2ee_device_id}")
852
+ try:
853
+ if credentials is not None:
854
+ credentials["device_id"] = e2ee_device_id
855
+ save_credentials(credentials)
856
+ logger.info("Updated credentials.json with discovered device_id")
857
+ except Exception as e:
858
+ logger.debug(f"Failed to persist discovered device_id: {e}")
416
859
  else:
417
- matrix_client.device_id = whoami_response.device_id
418
- if matrix_client.device_id:
419
- logger.debug(f"Retrieved device_id: {matrix_client.device_id}")
860
+ # Fallback to direct assignment for legacy token-based auth
861
+ matrix_client.access_token = matrix_access_token
862
+ matrix_client.user_id = bot_user_id
863
+
864
+ # If E2EE is enabled, upload keys if necessary.
865
+ # nio will have loaded the store automatically if store_path was provided.
866
+ if e2ee_enabled:
867
+ try:
868
+ if matrix_client.should_upload_keys:
869
+ logger.info("Uploading encryption keys...")
870
+ await matrix_client.keys_upload()
871
+ logger.info("Encryption keys uploaded successfully")
872
+ else:
873
+ logger.debug("No key upload needed - keys already present")
874
+ except Exception as e:
875
+ logger.error(f"Failed to upload E2EE keys: {e}")
876
+ # E2EE might still work, so we don't disable it here
877
+ logger.error("Consider regenerating credentials with: mmrelay auth login")
878
+
879
+ # Perform initial sync to populate rooms (needed for message delivery)
880
+ logger.debug("Performing initial sync to initialize rooms...")
881
+ try:
882
+ # A full_state=True sync is required to get room encryption state
883
+ sync_response = await asyncio.wait_for(
884
+ matrix_client.sync(timeout=MATRIX_EARLY_SYNC_TIMEOUT, full_state=True),
885
+ timeout=MATRIX_SYNC_OPERATION_TIMEOUT,
886
+ )
887
+ # Check if sync failed by looking for error class name
888
+ if (
889
+ hasattr(sync_response, "__class__")
890
+ and "Error" in sync_response.__class__.__name__
891
+ ):
892
+ logger.error(f"Initial sync failed: {sync_response}")
893
+ raise ConnectionError(f"Matrix sync failed: {sync_response}")
420
894
  else:
421
- logger.warning("device_id not returned by whoami()")
895
+ logger.info(
896
+ f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
897
+ )
898
+
899
+ # List all rooms with unified E2EE status display
900
+ from mmrelay.config import config_path
901
+ from mmrelay.e2ee_utils import (
902
+ get_e2ee_status,
903
+ get_room_encryption_warnings,
904
+ )
905
+
906
+ # Get comprehensive E2EE status
907
+ e2ee_status = get_e2ee_status(config, config_path)
908
+
909
+ # Display rooms with channel mappings
910
+ _display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
911
+
912
+ # Show warnings for encrypted rooms when E2EE is not ready
913
+ warnings = get_room_encryption_warnings(matrix_client.rooms, e2ee_status)
914
+ for warning in warnings:
915
+ logger.warning(warning)
916
+
917
+ # Debug information
918
+ encrypted_count = sum(
919
+ 1
920
+ for room in matrix_client.rooms.values()
921
+ if getattr(room, "encrypted", False)
922
+ )
923
+ logger.debug(
924
+ f"Found {encrypted_count} encrypted rooms out of {len(matrix_client.rooms)} total rooms"
925
+ )
926
+ logger.debug(f"E2EE status: {e2ee_status['overall_status']}")
927
+
928
+ # Additional debugging for E2EE enabled case
929
+ if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
930
+ logger.debug("No encrypted rooms detected - all rooms are plaintext")
931
+ except asyncio.TimeoutError:
932
+ logger.error(
933
+ f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
934
+ )
935
+ raise
936
+
937
+ # Add a delay to allow for key sharing to complete
938
+ # This addresses a race condition where the client attempts to send encrypted messages
939
+ # before it has received and processed room key sharing messages from other devices.
940
+ # The initial sync() call triggers key sharing requests, but the actual key exchange
941
+ # happens asynchronously. Without this delay, outgoing messages may be sent unencrypted
942
+ # even to encrypted rooms. While not ideal, this timing-based approach is necessary
943
+ # because matrix-nio doesn't provide event-driven alternatives to detect when key
944
+ # sharing is complete.
945
+ if e2ee_enabled:
946
+ logger.debug(
947
+ f"Waiting for {E2EE_KEY_SHARING_DELAY_SECONDS} seconds to allow for key sharing..."
948
+ )
949
+ await asyncio.sleep(E2EE_KEY_SHARING_DELAY_SECONDS)
422
950
 
423
951
  # Fetch the bot's display name
424
952
  response = await matrix_client.get_displayname(bot_user_id)
@@ -427,11 +955,233 @@ async def connect_matrix(passed_config=None):
427
955
  else:
428
956
  bot_user_name = bot_user_id # Fallback if display name is not set
429
957
 
958
+ # Set E2EE status on the client for other functions to access
959
+ matrix_client.e2ee_enabled = e2ee_enabled
960
+
430
961
  return matrix_client
431
962
 
432
963
 
964
+ async def login_matrix_bot(
965
+ homeserver=None, username=None, password=None, logout_others=False
966
+ ):
967
+ """
968
+ Perform an interactive Matrix login for the bot, enable end-to-end encryption, and persist credentials for later use.
969
+
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.
971
+
972
+ Parameters:
973
+ homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
974
+ username (str | None): Matrix username (without or with leading "@"). If None, the user is prompted.
975
+ password (str | None): Password for the account. If None, the user is prompted securely.
976
+ logout_others (bool | None): If True, attempts to log out other sessions after login. If None, the user is prompted. (Note: full "logout others" behavior may be limited.)
977
+
978
+ Returns:
979
+ bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
980
+ """
981
+ 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)
987
+
988
+ # Get homeserver URL
989
+ if not homeserver:
990
+ homeserver = input(
991
+ "Enter Matrix homeserver URL (e.g., https://matrix.org): "
992
+ )
993
+
994
+ # Ensure homeserver URL has the correct format
995
+ if not (homeserver.startswith("https://") or homeserver.startswith("http://")):
996
+ homeserver = "https://" + homeserver
997
+
998
+ # Step 1: Perform server discovery to get the actual homeserver URL
999
+ logger.info(f"Performing server discovery for {homeserver}...")
1000
+
1001
+ # Create SSL context using certifi's certificates
1002
+ ssl_context = _create_ssl_context()
1003
+ if ssl_context is None:
1004
+ logger.warning(
1005
+ "Failed to create SSL context for server discovery; falling back to default system SSL"
1006
+ )
1007
+
1008
+ # Create a temporary client for discovery
1009
+ temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
1010
+ try:
1011
+ discovery_response = await asyncio.wait_for(
1012
+ temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
1013
+ )
1014
+
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}"
1022
+ )
1023
+ # Continue with original homeserver URL
1024
+
1025
+ except asyncio.TimeoutError:
1026
+ logger.warning(
1027
+ f"Server discovery timed out, using original URL: {homeserver}"
1028
+ )
1029
+ # Continue with original homeserver URL
1030
+ except Exception as e:
1031
+ logger.warning(
1032
+ f"Server discovery error: {e}, using original URL: {homeserver}"
1033
+ )
1034
+ # Continue with original homeserver URL
1035
+ finally:
1036
+ await temp_client.close()
1037
+
1038
+ # Get username
1039
+ if not username:
1040
+ username = input("Enter Matrix username (without @): ")
1041
+
1042
+ # 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}"
1049
+
1050
+ logger.info(f"Using username: {username}")
1051
+
1052
+ # Get password
1053
+ if not password:
1054
+ password = getpass.getpass("Enter Matrix password: ")
1055
+
1056
+ # Ask about logging out other sessions
1057
+ if logout_others is None:
1058
+ logout_others_input = input(
1059
+ "Log out other sessions? (Y/n) [Default: Yes]: "
1060
+ ).lower()
1061
+ logout_others = (
1062
+ not logout_others_input.startswith("n") if logout_others_input else True
1063
+ )
1064
+
1065
+ # Check for existing credentials to reuse device_id
1066
+ existing_device_id = None
1067
+ try:
1068
+ config_dir = get_base_dir()
1069
+ credentials_path = os.path.join(config_dir, "credentials.json")
1070
+
1071
+ if os.path.exists(credentials_path):
1072
+ with open(credentials_path, "r") as f:
1073
+ existing_creds = json.load(f)
1074
+ if (
1075
+ "device_id" in existing_creds
1076
+ and existing_creds["user_id"] == username
1077
+ ):
1078
+ existing_device_id = existing_creds["device_id"]
1079
+ logger.info(f"Reusing existing device_id: {existing_device_id}")
1080
+ except Exception as e:
1081
+ logger.debug(f"Could not load existing credentials: {e}")
1082
+
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}")
1087
+
1088
+ # Create client config for E2EE
1089
+ client_config = AsyncClientConfig(
1090
+ store_sync_tokens=True, encryption_enabled=True
1091
+ )
1092
+
1093
+ # Use the same SSL context as discovery client
1094
+ # ssl_context was created above for discovery
1095
+
1096
+ # Initialize client with E2EE support
1097
+ # Use most common pattern from matrix-nio examples: positional homeserver and user
1098
+ client = AsyncClient(
1099
+ homeserver,
1100
+ username,
1101
+ device_id=existing_device_id,
1102
+ store_path=store_path,
1103
+ config=client_config,
1104
+ ssl=ssl_context,
1105
+ )
1106
+
1107
+ logger.info(f"Logging in as {username} to {homeserver}...")
1108
+
1109
+ # Login with consistent device name and timeout
1110
+ # Use the original working device name
1111
+ device_name = "mmrelay-e2ee"
1112
+ try:
1113
+ # Set device_id on client if we have an existing one
1114
+ if existing_device_id:
1115
+ client.device_id = existing_device_id
1116
+
1117
+ response = await asyncio.wait_for(
1118
+ client.login(password, device_name=device_name),
1119
+ timeout=MATRIX_LOGIN_TIMEOUT,
1120
+ )
1121
+ except asyncio.TimeoutError:
1122
+ logger.error(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
1123
+ logger.error(
1124
+ "This may indicate network connectivity issues or a slow Matrix server"
1125
+ )
1126
+ await client.close()
1127
+ return False
1128
+ except Exception as e:
1129
+ # 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}")
1134
+ await client.close()
1135
+ return False
1136
+
1137
+ if hasattr(response, "access_token"):
1138
+ logger.info("Login successful!")
1139
+
1140
+ # Save credentials to credentials.json
1141
+ credentials = {
1142
+ "homeserver": homeserver,
1143
+ "user_id": username,
1144
+ "access_token": response.access_token,
1145
+ "device_id": response.device_id,
1146
+ }
1147
+
1148
+ config_dir = get_base_dir()
1149
+ credentials_path = os.path.join(config_dir, "credentials.json")
1150
+ save_credentials(credentials)
1151
+ logger.info(f"Credentials saved to {credentials_path}")
1152
+
1153
+ # Logout other sessions if requested
1154
+ if logout_others:
1155
+ logger.info("Logging out other sessions...")
1156
+ # Note: This would require additional implementation
1157
+ logger.warning("Logout others not yet implemented")
1158
+
1159
+ await client.close()
1160
+ return True
1161
+ 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}")
1168
+ await client.close()
1169
+ return False
1170
+
1171
+ except Exception as e:
1172
+ logger.error(f"Error during login: {e}")
1173
+ return False
1174
+
1175
+
433
1176
  async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
434
- """Join a Matrix room by its ID or alias."""
1177
+ """
1178
+ Join a Matrix room by ID or alias, resolving aliases and updating the local matrix_rooms mapping.
1179
+
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).
1181
+
1182
+ Parameters:
1183
+ room_id_or_alias (str): Room ID (e.g. "!abcdef:server") or alias (e.g. "#room:server") to join.
1184
+ """
435
1185
  try:
436
1186
  if room_id_or_alias.startswith("#"):
437
1187
  # If it's a room alias, resolve it to a room ID
@@ -457,7 +1207,7 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
457
1207
  logger.info(f"Joined room '{room_id_or_alias}' successfully")
458
1208
  else:
459
1209
  logger.error(
460
- f"Failed to join room '{room_id_or_alias}': {response.message}"
1210
+ f"Failed to join room '{room_id_or_alias}': {getattr(response, 'message', str(response))}"
461
1211
  )
462
1212
  else:
463
1213
  logger.debug(f"Bot is already in room '{room_id_or_alias}'")
@@ -465,6 +1215,21 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
465
1215
  logger.error(f"Error joining room '{room_id_or_alias}': {e}")
466
1216
 
467
1217
 
1218
+ def _get_e2ee_error_message():
1219
+ """
1220
+ Return a specific error message for why E2EE is not properly enabled.
1221
+ Uses the unified E2EE status system for consistent messaging.
1222
+ """
1223
+ from mmrelay.config import config_path
1224
+ from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
1225
+
1226
+ # Get unified E2EE status
1227
+ e2ee_status = get_e2ee_status(config, config_path)
1228
+
1229
+ # Return unified error message
1230
+ return get_e2ee_error_message(e2ee_status)
1231
+
1232
+
468
1233
  async def matrix_relay(
469
1234
  room_id,
470
1235
  message,
@@ -480,23 +1245,31 @@ async def matrix_relay(
480
1245
  reply_to_event_id=None,
481
1246
  ):
482
1247
  """
483
- Relay a message from the Meshtastic network to a Matrix room, supporting replies, emotes, emoji reactions, and message mapping for future interactions.
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.
484
1249
 
485
- If a reply target is specified, formats the message as a Matrix reply with quoted original content. When message interactions (reactions or replies) are enabled, stores a mapping between the Meshtastic message ID and the resulting Matrix event ID to support cross-network interactions. Prunes old message mappings according to configuration to limit storage.
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.
486
1251
 
487
1252
  Parameters:
488
- room_id (str): The Matrix room ID to send the message to.
489
- message (str): The message content to relay.
490
- longname (str): The sender's long display name from Meshtastic.
491
- shortname (str): The sender's short display name from Meshtastic.
492
- meshnet_name (str): The originating meshnet name.
493
- portnum (int): The Meshtastic port number.
494
- meshtastic_id (str, optional): The Meshtastic message ID for mapping.
495
- meshtastic_replyId (str, optional): The Meshtastic message ID being replied to, if any.
496
- meshtastic_text (str, optional): The original Meshtastic message text.
497
- emote (bool, optional): Whether to send the message as an emote.
498
- emoji (bool, optional): Whether the message is an emoji reaction.
499
- reply_to_event_id (str, optional): The Matrix event ID being replied to, if sending a reply.
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.
1265
+
1266
+ 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).
1270
+
1271
+ Returns:
1272
+ None
500
1273
  """
501
1274
  global config
502
1275
 
@@ -536,14 +1309,55 @@ async def matrix_relay(
536
1309
  try:
537
1310
  # Always use our own local meshnet_name for outgoing events
538
1311
  local_meshnet_name = config["meshtastic"]["meshnet_name"]
1312
+
1313
+ # Check if message contains HTML tags or markdown formatting
1314
+ has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
1315
+ has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
1316
+
1317
+ # Process markdown to HTML if needed (like base plugin does)
1318
+ 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)
1344
+ else:
1345
+ formatted_body = html.escape(message).replace("\n", "<br/>")
1346
+ plain_body = message
1347
+
539
1348
  content = {
540
1349
  "msgtype": "m.text" if not emote else "m.emote",
541
- "body": message,
1350
+ "body": plain_body,
542
1351
  "meshtastic_longname": longname,
543
1352
  "meshtastic_shortname": shortname,
544
1353
  "meshtastic_meshnet": local_meshnet_name,
545
1354
  "meshtastic_portnum": portnum,
546
1355
  }
1356
+
1357
+ # Always add format and formatted_body to avoid nio validation errors
1358
+ # where formatted_body becomes None and fails schema validation.
1359
+ content["format"] = "org.matrix.custom.html"
1360
+ content["formatted_body"] = formatted_body
547
1361
  if meshtastic_id is not None:
548
1362
  content["meshtastic_id"] = meshtastic_id
549
1363
  if meshtastic_replyId is not None:
@@ -569,16 +1383,26 @@ async def matrix_relay(
569
1383
  original_sender_display = f"{longname}/{original_meshnet}"
570
1384
 
571
1385
  # Create the quoted reply format
572
- quoted_text = f"> <@{bot_user_id}> [{original_sender_display}]: {original_text}"
573
- content["body"] = f"{quoted_text}\n\n{message}"
574
- content["format"] = "org.matrix.custom.html"
1386
+ safe_original = html.escape(original_text or "")
1387
+ safe_sender_display = re.sub(
1388
+ r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
1389
+ )
1390
+ quoted_text = (
1391
+ f"> <@{bot_user_id}> [{safe_sender_display}]: {safe_original}"
1392
+ )
1393
+ content["body"] = f"{quoted_text}\n\n{plain_body}"
575
1394
 
576
- # Create formatted HTML version with better readability
1395
+ # Always use HTML formatting for replies since we need the mx-reply structure
1396
+ content["format"] = "org.matrix.custom.html"
577
1397
  reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
578
1398
  bot_link = f"https://matrix.to/#/@{bot_user_id}"
579
- blockquote_content = f'<a href="{reply_link}">In reply to</a> <a href="{bot_link}">@{bot_user_id}</a><br>[{original_sender_display}]: {original_text}'
1399
+ blockquote_content = (
1400
+ f'<a href="{reply_link}">In reply to</a> '
1401
+ f'<a href="{bot_link}">@{bot_user_id}</a><br>'
1402
+ f"[{html.escape(original_sender_display)}]: {safe_original}"
1403
+ )
580
1404
  content["formatted_body"] = (
581
- f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{message}"
1405
+ f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{formatted_body}"
582
1406
  )
583
1407
  else:
584
1408
  logger.warning(
@@ -594,13 +1418,66 @@ async def matrix_relay(
594
1418
  return
595
1419
 
596
1420
  # Send the message with a timeout
1421
+ # For encrypted rooms, use ignore_unverified_devices=True
1422
+ # After checking working implementations, always use ignore_unverified_devices=True
1423
+ # for text messages to ensure encryption works properly
1424
+ room = (
1425
+ matrix_client.rooms.get(room_id)
1426
+ if matrix_client and hasattr(matrix_client, "rooms")
1427
+ else None
1428
+ )
1429
+
1430
+ # Debug logging for encryption status
1431
+ if room:
1432
+ encrypted_status = getattr(room, "encrypted", "unknown")
1433
+ logger.debug(
1434
+ f"Room {room_id} encryption status: encrypted={encrypted_status}"
1435
+ )
1436
+
1437
+ # Additional E2EE debugging
1438
+ if encrypted_status is True:
1439
+ logger.debug(f"Sending encrypted message to room {room_id}")
1440
+ elif encrypted_status is False:
1441
+ logger.debug(f"Sending unencrypted message to room {room_id}")
1442
+ else:
1443
+ logger.warning(
1444
+ f"Room {room_id} encryption status is unknown - this may indicate E2EE issues"
1445
+ )
1446
+ else:
1447
+ logger.warning(
1448
+ f"Room {room_id} not found in client.rooms - cannot determine encryption status"
1449
+ )
1450
+
1451
+ # Always use ignore_unverified_devices=True for text messages (like matrix-nio-send)
1452
+ logger.debug(
1453
+ "Sending message with ignore_unverified_devices=True (always for text messages)"
1454
+ )
1455
+
1456
+ # Final check: Do not send to encrypted rooms if E2EE is not enabled
1457
+ if (
1458
+ room
1459
+ and getattr(room, "encrypted", False)
1460
+ and not getattr(matrix_client, "e2ee_enabled", False)
1461
+ ):
1462
+ room_name = getattr(room, "display_name", room_id)
1463
+ error_message = _get_e2ee_error_message()
1464
+ logger.error(
1465
+ f"🔒 BLOCKED: Cannot send message to encrypted room '{room_name}' ({room_id})"
1466
+ )
1467
+ logger.error(f"Reason: {error_message}")
1468
+ logger.info(
1469
+ "💡 Tip: Run 'mmrelay config check' to validate your E2EE setup"
1470
+ )
1471
+ return
1472
+
597
1473
  response = await asyncio.wait_for(
598
1474
  matrix_client.room_send(
599
1475
  room_id=room_id,
600
1476
  message_type="m.room.message",
601
1477
  content=content,
1478
+ ignore_unverified_devices=True,
602
1479
  ),
603
- timeout=10.0, # Increased timeout
1480
+ timeout=MATRIX_ROOM_SEND_TIMEOUT, # Increased timeout
604
1481
  )
605
1482
 
606
1483
  # Log at info level, matching one-point-oh pattern
@@ -647,7 +1524,7 @@ async def matrix_relay(
647
1524
  logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
648
1525
 
649
1526
 
650
- def truncate_message(text, max_bytes=227):
1527
+ def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
651
1528
  """
652
1529
  Truncate the given text to fit within the specified byte size.
653
1530
 
@@ -718,16 +1595,39 @@ async def send_reply_to_meshtastic(
718
1595
  reply_id=None,
719
1596
  ):
720
1597
  """
721
- Queues a reply message from Matrix to be sent to Meshtastic, optionally as a structured reply, and includes message mapping metadata if storage is enabled.
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
722
1615
 
723
- If a `reply_id` is provided, the message is sent as a structured reply to the referenced Meshtastic message; otherwise, it is sent as a regular message. When message storage is enabled, mapping information is attached for future interaction tracking. The function logs the outcome of the queuing operation.
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.
724
1619
  """
725
1620
  meshtastic_interface = connect_meshtastic()
726
1621
  from mmrelay.meshtastic_utils import logger as meshtastic_logger
727
1622
 
728
1623
  meshtastic_channel = room_config["meshtastic_channel"]
729
1624
 
730
- if config["meshtastic"]["broadcast_enabled"]:
1625
+ broadcast_enabled = get_meshtastic_config_value(
1626
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
1627
+ )
1628
+ logger.debug(f"broadcast_enabled = {broadcast_enabled}")
1629
+
1630
+ if broadcast_enabled:
731
1631
  try:
732
1632
  # Create mapping info once if storage is enabled
733
1633
  mapping_info = None
@@ -859,16 +1759,79 @@ async def handle_matrix_reply(
859
1759
  return True # Reply was handled, stop further processing
860
1760
 
861
1761
 
1762
+ async def on_decryption_failure(room: MatrixRoom, event: MegolmEvent) -> None:
1763
+ """
1764
+ Handle a MegolmEvent that failed to decrypt by requesting the needed session keys.
1765
+
1766
+ If a received encrypted event cannot be decrypted, this callback logs an error and attempts to request the missing keys from the device that sent them by creating and sending a to-device key request via the module-level Matrix client. The function will:
1767
+ - Set event.room_id to the room's id (monkey-patch) so the key request is properly scoped.
1768
+ - Create a key request from the event and send it with matrix_client.to_device().
1769
+ - Log success or any errors encountered.
1770
+
1771
+ If the module-level Matrix client is not available, the function logs an error and returns without sending a request.
1772
+ """
1773
+ logger.error(
1774
+ f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'! "
1775
+ f"This is usually temporary and resolves on its own. "
1776
+ f"If this persists, the bot's session may be corrupt. "
1777
+ f"{msg_retry_auth_login()}."
1778
+ )
1779
+
1780
+ # Attempt to request the keys for the failed event
1781
+ try:
1782
+ if not matrix_client:
1783
+ logger.error("Matrix client not available, cannot request keys.")
1784
+ return
1785
+
1786
+ # Monkey-patch the event object with the correct room_id
1787
+ event.room_id = room.room_id
1788
+
1789
+ request = event.as_key_request(matrix_client.user_id, matrix_client.device_id)
1790
+ await matrix_client.to_device(request)
1791
+ 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}")
1794
+
1795
+
862
1796
  # Callback for new messages in Matrix room
863
1797
  async def on_room_message(
864
1798
  room: MatrixRoom,
865
- event: Union[RoomMessageText, RoomMessageNotice, ReactionEvent, RoomMessageEmote],
1799
+ event: Union[
1800
+ RoomMessageText,
1801
+ RoomMessageNotice,
1802
+ ReactionEvent,
1803
+ RoomMessageEmote,
1804
+ ],
866
1805
  ) -> None:
867
1806
  """
868
- Asynchronously handles incoming Matrix room messages, reactions, and replies, relaying them to Meshtastic as appropriate.
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:
1810
+ - 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
+
1819
+ 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.
869
1823
 
870
- Processes Matrix events—including text messages, reactions, and replies—in configured rooms. Relays supported messages to the Meshtastic mesh network if broadcasting is enabled, applying message mapping for cross-referencing when reactions or replies are enabled. Ignores messages from the bot itself, messages sent before the bot started, and reactions to reactions. Integrates with plugins for command and message handling; only messages not handled by plugins or identified as commands are forwarded to Meshtastic, with appropriate formatting and truncation. Handles special cases for relaying messages and reactions from remote mesh networks and detection sensor data.
1824
+ Returns:
1825
+ - None
871
1826
  """
1827
+ # DEBUG: Log all Matrix message events to trace reception
1828
+ logger.debug(
1829
+ f"Received Matrix event in room {room.room_id}: {type(event).__name__}"
1830
+ )
1831
+ logger.debug(
1832
+ f"Event details - sender: {event.sender}, timestamp: {event.server_timestamp}"
1833
+ )
1834
+
872
1835
  # Importing here to avoid circular imports and to keep logic consistent
873
1836
  # Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
874
1837
  from mmrelay.message_queue import get_message_queue
@@ -885,6 +1848,10 @@ async def on_room_message(
885
1848
  if event.sender == bot_user_id:
886
1849
  return
887
1850
 
1851
+ # Note: MegolmEvent (encrypted) messages are handled by the `on_decryption_failure`
1852
+ # callback if they fail to decrypt. Successfully decrypted messages are automatically
1853
+ # converted to RoomMessageText/RoomMessageNotice/etc. by matrix-nio and handled normally.
1854
+
888
1855
  # Find the room_config that matches this room, if any
889
1856
  room_config = None
890
1857
  for room_conf in matrix_rooms:
@@ -982,12 +1949,12 @@ async def on_room_message(
982
1949
  ):
983
1950
  logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
984
1951
 
985
- short_meshnet_name = meshnet_name[:4]
1952
+ short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
986
1953
 
987
1954
  # Format the reaction message for relaying to the local meshnet.
988
1955
  # The necessary information is in the m.emote event
989
1956
  if not shortname:
990
- shortname = longname[:3] if longname else "???"
1957
+ shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
991
1958
 
992
1959
  meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
993
1960
  # Strip out any quoted lines from the text
@@ -997,8 +1964,8 @@ async def on_room_message(
997
1964
  )
998
1965
 
999
1966
  abbreviated_text = (
1000
- meshtastic_text_db[:40] + "..."
1001
- if len(meshtastic_text_db) > 40
1967
+ meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
1968
+ if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
1002
1969
  else meshtastic_text_db
1003
1970
  )
1004
1971
 
@@ -1010,7 +1977,9 @@ async def on_room_message(
1010
1977
 
1011
1978
  meshtastic_channel = room_config["meshtastic_channel"]
1012
1979
 
1013
- if config["meshtastic"]["broadcast_enabled"]:
1980
+ if get_meshtastic_config_value(
1981
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
1982
+ ):
1014
1983
  meshtastic_logger.info(
1015
1984
  f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
1016
1985
  )
@@ -1069,8 +2038,8 @@ async def on_room_message(
1069
2038
  )
1070
2039
 
1071
2040
  abbreviated_text = (
1072
- meshtastic_text_db[:40] + "..."
1073
- if len(meshtastic_text_db) > 40
2041
+ meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
2042
+ if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
1074
2043
  else meshtastic_text_db
1075
2044
  )
1076
2045
 
@@ -1083,7 +2052,9 @@ async def on_room_message(
1083
2052
 
1084
2053
  meshtastic_channel = room_config["meshtastic_channel"]
1085
2054
 
1086
- if config["meshtastic"]["broadcast_enabled"]:
2055
+ if get_meshtastic_config_value(
2056
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2057
+ ):
1087
2058
  meshtastic_logger.info(
1088
2059
  f"Relaying reaction from {full_display_name} to radio broadcast"
1089
2060
  )
@@ -1129,10 +2100,10 @@ async def on_room_message(
1129
2100
  if meshnet_name != local_meshnet_name:
1130
2101
  # A message from a remote meshnet relayed into Matrix, now going back out
1131
2102
  logger.info(f"Processing message from remote meshnet: {meshnet_name}")
1132
- short_meshnet_name = meshnet_name[:4]
2103
+ short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
1133
2104
  # If shortname is not available, derive it from the longname
1134
2105
  if shortname is None:
1135
- shortname = longname[:3] if longname else "???"
2106
+ shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
1136
2107
  # Remove the original prefix to avoid double-tagging
1137
2108
  # Get the prefix that would have been used for this message
1138
2109
  original_prefix = get_matrix_prefix(
@@ -1163,7 +2134,7 @@ async def on_room_message(
1163
2134
  prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
1164
2135
  logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
1165
2136
  full_message = f"{prefix}{text}"
1166
- text = truncate_message(text)
2137
+ full_message = truncate_message(full_message)
1167
2138
 
1168
2139
  # Plugin functionality
1169
2140
  from mmrelay.plugin_loader import load_plugins
@@ -1215,11 +2186,15 @@ async def on_room_message(
1215
2186
  # Note: If relay_reactions is False, we won't store message_map, but we can still relay.
1216
2187
  # The lack of message_map storage just means no reaction bridging will occur.
1217
2188
  if not found_matching_plugin:
1218
- if config["meshtastic"]["broadcast_enabled"]:
2189
+ if get_meshtastic_config_value(
2190
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2191
+ ):
1219
2192
  portnum = event.source["content"].get("meshtastic_portnum")
1220
2193
  if portnum == DETECTION_SENSOR_APP:
1221
2194
  # If detection_sensor is enabled, forward this data as detection sensor data
1222
- if config["meshtastic"].get("detection_sensor", False):
2195
+ if get_meshtastic_config_value(
2196
+ config, "detection_sensor", DEFAULT_DETECTION_SENSOR
2197
+ ):
1223
2198
  success = queue_message(
1224
2199
  meshtastic_interface.sendData,
1225
2200
  data=full_message.encode("utf-8"),
@@ -1295,7 +2270,7 @@ async def on_room_message(
1295
2270
  # Message mapping is now handled automatically by the queue system
1296
2271
  else:
1297
2272
  logger.debug(
1298
- f"Broadcast not supported: Message from {full_display_name} dropped."
2273
+ f"broadcast_enabled is False - not relaying message from {full_display_name} to Meshtastic"
1299
2274
  )
1300
2275
 
1301
2276