mmrelay 1.1.2__py3-none-any.whl → 1.1.4__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
@@ -21,6 +21,18 @@ from nio import (
21
21
  from nio.events.room_events import RoomMemberEvent
22
22
  from PIL import Image
23
23
 
24
+ from mmrelay.constants.config import (
25
+ CONFIG_KEY_ACCESS_TOKEN,
26
+ CONFIG_KEY_HOMESERVER,
27
+ CONFIG_SECTION_MATRIX,
28
+ )
29
+ from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
30
+ from mmrelay.constants.formats import (
31
+ DEFAULT_MATRIX_PREFIX,
32
+ DEFAULT_MESHTASTIC_PREFIX,
33
+ DETECTION_SENSOR_APP,
34
+ )
35
+ from mmrelay.constants.network import MILLISECONDS_PER_SECOND
24
36
  from mmrelay.db_utils import (
25
37
  get_message_map_by_matrix_event_id,
26
38
  prune_message_map,
@@ -29,14 +41,69 @@ from mmrelay.db_utils import (
29
41
  from mmrelay.log_utils import get_logger
30
42
 
31
43
  # Do not import plugin_loader here to avoid circular imports
32
- from mmrelay.meshtastic_utils import connect_meshtastic
44
+ from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
45
+ from mmrelay.message_queue import get_message_queue, queue_message
46
+
47
+ logger = get_logger(name="matrix_utils")
48
+
49
+
50
+ def _get_msgs_to_keep_config():
51
+ """
52
+ Returns the configured number of messages to retain for message mapping, supporting both current and legacy configuration sections.
53
+
54
+ Returns:
55
+ int: Number of messages to keep for message mapping; defaults to the predefined constant if not set.
56
+ """
57
+ global config
58
+ if not config:
59
+ return DEFAULT_MSGS_TO_KEEP
60
+
61
+ msg_map_config = config.get("database", {}).get("msg_map", {})
62
+
63
+ # If not found in database config, check legacy db config
64
+ if not msg_map_config:
65
+ legacy_msg_map_config = config.get("db", {}).get("msg_map", {})
66
+
67
+ if legacy_msg_map_config:
68
+ msg_map_config = legacy_msg_map_config
69
+ logger.warning(
70
+ "Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
71
+ )
72
+
73
+ return msg_map_config.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
74
+
75
+
76
+ def _create_mapping_info(
77
+ matrix_event_id, room_id, text, meshnet=None, msgs_to_keep=None
78
+ ):
79
+ """
80
+ Create a metadata dictionary linking a Matrix event to a Meshtastic message for message mapping.
81
+
82
+ Removes quoted lines from the message text and includes identifiers and message retention settings. Returns `None` if any required parameter is missing.
83
+
84
+ Returns:
85
+ dict: Mapping information for the message queue, or `None` if required fields are missing.
86
+ """
87
+ if not matrix_event_id or not room_id or not text:
88
+ return None
89
+
90
+ if msgs_to_keep is None:
91
+ msgs_to_keep = _get_msgs_to_keep_config()
92
+
93
+ return {
94
+ "matrix_event_id": matrix_event_id,
95
+ "room_id": room_id,
96
+ "text": strip_quoted_lines(text),
97
+ "meshnet": meshnet,
98
+ "msgs_to_keep": msgs_to_keep,
99
+ }
33
100
 
34
101
 
35
102
  def get_interaction_settings(config):
36
103
  """
37
- Returns a dictionary indicating whether message reactions and replies are enabled based on the provided configuration.
104
+ Determine if message reactions and replies are enabled in the configuration.
38
105
 
39
- Supports both the new `message_interactions` structure and the legacy `relay_reactions` flag for backward compatibility. Defaults to disabling both features if not specified.
106
+ Checks for both the new `message_interactions` structure and the legacy `relay_reactions` flag for backward compatibility. Returns a dictionary with boolean values for `reactions` and `replies`, defaulting to both disabled if not specified.
40
107
  """
41
108
  if config is None:
42
109
  return {"reactions": False, "replies": False}
@@ -70,15 +137,175 @@ def get_interaction_settings(config):
70
137
 
71
138
  def message_storage_enabled(interactions):
72
139
  """
73
- Return True if message storage is required for enabled message interactions.
140
+ Determine if message storage is needed based on enabled message interactions.
141
+
142
+ Returns:
143
+ True if either reactions or replies are enabled in the interactions dictionary; otherwise, False.
144
+ """
145
+ return interactions["reactions"] or interactions["replies"]
146
+
147
+
148
+ def _add_truncated_vars(format_vars, prefix, text):
149
+ """Helper function to add variable length truncation variables to format_vars dict."""
150
+ # Always add truncated variables, even for empty text (to prevent KeyError)
151
+ text = text or "" # Convert None to empty string
152
+ 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
154
+ truncated_value = text[:i]
155
+ format_vars[f"{prefix}{i}"] = truncated_value
156
+ if i <= 6: # Only log first few to avoid spam
157
+ logger.debug(f" {prefix}{i} = '{truncated_value}'")
158
+
159
+
160
+ def validate_prefix_format(format_string, available_vars):
161
+ """Validate prefix format string against available variables.
162
+
163
+ Args:
164
+ format_string (str): The format string to validate.
165
+ available_vars (dict): Dictionary of available variables with test values.
166
+
167
+ Returns:
168
+ tuple: (is_valid: bool, error_message: str or None)
169
+ """
170
+ try:
171
+ # Test format with dummy data
172
+ format_string.format(**available_vars)
173
+ return True, None
174
+ except (KeyError, ValueError) as e:
175
+ return False, str(e)
176
+
177
+
178
+ def get_meshtastic_prefix(config, display_name, user_id=None):
179
+ """
180
+ Generate a Meshtastic message prefix using the configured format, supporting variable-length truncation and user-specific variables.
181
+
182
+ If prefix formatting is enabled in the configuration, returns a formatted prefix string for Meshtastic messages using the user's display name and optional Matrix user ID. Supports custom format strings with placeholders for the display name, truncated display name segments (e.g., `{display5}`), and user ID components. Falls back to a default format if the custom format is invalid or missing. Returns an empty string if prefixing is disabled.
183
+
184
+ Args:
185
+ config (dict): The application configuration dictionary.
186
+ display_name (str): The user's display name (room-specific or global).
187
+ user_id (str, optional): The user's Matrix ID (@user:server.com).
188
+
189
+ Returns:
190
+ str: The formatted prefix string if enabled, empty string otherwise.
191
+
192
+ Examples:
193
+ Basic usage:
194
+ get_meshtastic_prefix(config, "Alice Smith")
195
+ # Returns: "Alice[M]: " (with default format)
196
+
197
+ Custom format:
198
+ config = {"meshtastic": {"prefix_format": "{display8}> "}}
199
+ get_meshtastic_prefix(config, "Alice Smith")
200
+ # Returns: "Alice Sm> "
201
+ """
202
+ meshtastic_config = config.get("meshtastic", {})
203
+
204
+ # Check if prefixes are enabled
205
+ if not meshtastic_config.get("prefix_enabled", True):
206
+ return ""
207
+
208
+ # Get custom format or use default
209
+ prefix_format = meshtastic_config.get("prefix_format", DEFAULT_MESHTASTIC_PREFIX)
210
+
211
+ # Parse username and server from user_id if available
212
+ username = ""
213
+ server = ""
214
+ if user_id:
215
+ # Extract username and server from @username:server.com format
216
+ if user_id.startswith("@") and ":" in user_id:
217
+ parts = user_id[1:].split(":", 1) # Remove @ and split on first :
218
+ username = parts[0]
219
+ server = parts[1] if len(parts) > 1 else ""
220
+
221
+ # Available variables for formatting with variable length support
222
+ format_vars = {
223
+ "display": display_name or "",
224
+ "user": user_id or "",
225
+ "username": username,
226
+ "server": server,
227
+ }
228
+
229
+ # Add variable length display name truncation (display1, display2, display3, etc.)
230
+ _add_truncated_vars(format_vars, "display", display_name)
231
+
232
+ try:
233
+ return prefix_format.format(**format_vars)
234
+ except (KeyError, ValueError) as e:
235
+ # Fallback to default format if custom format is invalid
236
+ logger.warning(
237
+ f"Invalid prefix_format '{prefix_format}': {e}. Using default format."
238
+ )
239
+ # The default format only uses 'display5', which is safe to format
240
+ return DEFAULT_MESHTASTIC_PREFIX.format(
241
+ display5=display_name[:5] if display_name else ""
242
+ )
243
+
244
+
245
+ def get_matrix_prefix(config, longname, shortname, meshnet_name):
246
+ """
247
+ Generates a formatted prefix string for Meshtastic messages relayed to Matrix, based on configuration settings and sender/mesh network names.
248
+
249
+ The prefix format supports variable-length truncation for the sender and mesh network names using template variables (e.g., `{long4}` for the first 4 characters of the sender name). Returns an empty string if prefixing is disabled in the configuration.
74
250
 
75
251
  Parameters:
76
- interactions (dict): Dictionary with 'reactions' and 'replies' boolean flags.
252
+ longname (str): Full Meshtastic sender name.
253
+ shortname (str): Short Meshtastic sender name.
254
+ meshnet_name (str): Name of the mesh network.
77
255
 
78
256
  Returns:
79
- bool: True if reactions or replies are enabled; otherwise, False.
257
+ str: The formatted prefix string, or an empty string if prefixing is disabled.
80
258
  """
81
- return interactions["reactions"] or interactions["replies"]
259
+ matrix_config = config.get(CONFIG_SECTION_MATRIX, {})
260
+
261
+ # Enhanced debug logging for configuration troubleshooting
262
+ logger.debug(
263
+ f"get_matrix_prefix called with longname='{longname}', shortname='{shortname}', meshnet_name='{meshnet_name}'"
264
+ )
265
+ logger.debug(f"Matrix config section: {matrix_config}")
266
+
267
+ # Check if prefixes are enabled for Matrix direction
268
+ if not matrix_config.get("prefix_enabled", True):
269
+ logger.debug("Matrix prefixes are disabled, returning empty string")
270
+ return ""
271
+
272
+ # Get custom format or use default
273
+ matrix_prefix_format = matrix_config.get("prefix_format", DEFAULT_MATRIX_PREFIX)
274
+ logger.debug(
275
+ f"Using matrix prefix format: '{matrix_prefix_format}' (default: '{DEFAULT_MATRIX_PREFIX}')"
276
+ )
277
+
278
+ # Available variables for formatting with variable length support
279
+ format_vars = {
280
+ "long": longname,
281
+ "short": shortname,
282
+ "mesh": meshnet_name,
283
+ }
284
+
285
+ # Add variable length truncation for longname and mesh name
286
+ _add_truncated_vars(format_vars, "long", longname)
287
+ _add_truncated_vars(format_vars, "mesh", meshnet_name)
288
+
289
+ try:
290
+ result = matrix_prefix_format.format(**format_vars)
291
+ logger.debug(
292
+ f"Matrix prefix generated: '{result}' using format '{matrix_prefix_format}' with vars {format_vars}"
293
+ )
294
+ # Additional debug to help identify the issue
295
+ if result == f"[{longname}/{meshnet_name}]: ":
296
+ logger.debug(
297
+ "Generated prefix matches default format - check if custom configuration is being loaded correctly"
298
+ )
299
+ return result
300
+ except (KeyError, ValueError) as e:
301
+ # Fallback to default format if custom format is invalid
302
+ logger.warning(
303
+ f"Invalid matrix prefix_format '{matrix_prefix_format}': {e}. Using default format."
304
+ )
305
+ # The default format only uses 'long' and 'mesh', which are safe
306
+ return DEFAULT_MATRIX_PREFIX.format(
307
+ long=longname or "", mesh=meshnet_name or ""
308
+ )
82
309
 
83
310
 
84
311
  # Global config variable that will be set from config.py
@@ -91,7 +318,7 @@ matrix_access_token = None
91
318
  bot_user_id = None
92
319
  bot_user_name = None # Detected upon logon
93
320
  bot_start_time = int(
94
- time.time() * 1000
321
+ time.time() * MILLISECONDS_PER_SECOND
95
322
  ) # Timestamp when the bot starts, used to filter out old messages
96
323
 
97
324
  logger = get_logger(name="Matrix")
@@ -136,11 +363,9 @@ def bot_command(command, event):
136
363
 
137
364
  async def connect_matrix(passed_config=None):
138
365
  """
139
- Establish a connection to the Matrix homeserver.
140
- Sets global matrix_client and detects the bot's display name.
366
+ Asynchronously connects to the Matrix homeserver, initializes the Matrix client, and retrieves the bot's device ID and display name.
141
367
 
142
- Args:
143
- passed_config: The configuration dictionary to use (will update global config)
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.
144
369
  """
145
370
  global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
146
371
 
@@ -154,9 +379,9 @@ async def connect_matrix(passed_config=None):
154
379
  return None
155
380
 
156
381
  # Extract Matrix configuration
157
- matrix_homeserver = config["matrix"]["homeserver"]
382
+ matrix_homeserver = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_HOMESERVER]
158
383
  matrix_rooms = config["matrix_rooms"]
159
- matrix_access_token = config["matrix"]["access_token"]
384
+ matrix_access_token = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_ACCESS_TOKEN]
160
385
  bot_user_id = config["matrix"]["bot_user_id"]
161
386
 
162
387
  # Check if client already exists
@@ -164,7 +389,11 @@ async def connect_matrix(passed_config=None):
164
389
  return matrix_client
165
390
 
166
391
  # Create SSL context using certifi's certificates
167
- ssl_context = ssl.create_default_context(cafile=certifi.where())
392
+ try:
393
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
394
+ 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
168
397
 
169
398
  # Initialize the Matrix client with custom SSL context
170
399
  client_config = AsyncClientConfig(encryption_enabled=False)
@@ -251,9 +480,9 @@ async def matrix_relay(
251
480
  reply_to_event_id=None,
252
481
  ):
253
482
  """
254
- Relays a message from Meshtastic to a Matrix room, supporting replies and message mapping for interactions.
483
+ Relay a message from the Meshtastic network to a Matrix room, supporting replies, emotes, emoji reactions, and message mapping for future interactions.
255
484
 
256
- If `reply_to_event_id` is provided, sends the message as a Matrix reply, formatting the content to include quoted original text and appropriate Matrix reply relations. When message interactions (reactions or replies) are enabled in the configuration, stores a mapping between the Meshtastic message ID and the resulting Matrix event ID to enable cross-referencing for future interactions. Prunes old message mappings based on configuration to limit storage.
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.
257
486
 
258
487
  Parameters:
259
488
  room_id (str): The Matrix room ID to send the message to.
@@ -268,9 +497,6 @@ async def matrix_relay(
268
497
  emote (bool, optional): Whether to send the message as an emote.
269
498
  emoji (bool, optional): Whether the message is an emoji reaction.
270
499
  reply_to_event_id (str, optional): The Matrix event ID being replied to, if sending a reply.
271
-
272
- Returns:
273
- None
274
500
  """
275
501
  global config
276
502
 
@@ -304,8 +530,8 @@ async def matrix_relay(
304
530
  "Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
305
531
  )
306
532
  msgs_to_keep = msg_map_config.get(
307
- "msgs_to_keep", 500
308
- ) # Default is 500 if not specified
533
+ "msgs_to_keep", DEFAULT_MSGS_TO_KEEP
534
+ ) # Default from constants
309
535
 
310
536
  try:
311
537
  # Always use our own local meshnet_name for outgoing events
@@ -459,7 +685,7 @@ async def get_user_display_name(room, event):
459
685
  return display_name_response.displayname or event.sender
460
686
 
461
687
 
462
- def format_reply_message(full_display_name, text):
688
+ def format_reply_message(config, full_display_name, text):
463
689
  """
464
690
  Format a reply message by prefixing a truncated display name and removing quoted lines.
465
691
 
@@ -472,8 +698,7 @@ def format_reply_message(full_display_name, text):
472
698
  Returns:
473
699
  str: The formatted and truncated reply message.
474
700
  """
475
- short_display_name = full_display_name[:5]
476
- prefix = f"{short_display_name}[M]: "
701
+ prefix = get_meshtastic_prefix(config, full_display_name)
477
702
 
478
703
  # Strip quoted content from the reply text
479
704
  clean_text = strip_quoted_lines(text)
@@ -493,82 +718,86 @@ async def send_reply_to_meshtastic(
493
718
  reply_id=None,
494
719
  ):
495
720
  """
496
- Sends a reply message from Matrix to Meshtastic and optionally stores the message mapping for future interactions.
497
-
498
- If message storage is enabled and the message is successfully sent, stores a mapping between the Meshtastic packet ID and the Matrix event ID, after removing quoted lines from the reply text. Prunes old message mappings based on configuration to limit storage size. Logs errors if sending fails.
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.
499
722
 
500
- Args:
501
- reply_id: If provided, sends as a structured Meshtastic reply to this message ID
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.
502
724
  """
503
725
  meshtastic_interface = connect_meshtastic()
504
726
  from mmrelay.meshtastic_utils import logger as meshtastic_logger
505
- from mmrelay.meshtastic_utils import sendTextReply
506
727
 
507
728
  meshtastic_channel = room_config["meshtastic_channel"]
508
729
 
509
730
  if config["meshtastic"]["broadcast_enabled"]:
510
731
  try:
732
+ # Create mapping info once if storage is enabled
733
+ mapping_info = None
734
+ if storage_enabled:
735
+ # Get message map configuration
736
+ msgs_to_keep = _get_msgs_to_keep_config()
737
+
738
+ mapping_info = _create_mapping_info(
739
+ event.event_id, room.room_id, text, local_meshnet_name, msgs_to_keep
740
+ )
741
+
511
742
  if reply_id is not None:
512
743
  # Send as a structured reply using our custom function
513
- try:
514
- sent_packet = sendTextReply(
515
- meshtastic_interface,
516
- text=reply_message,
517
- reply_id=reply_id,
518
- channelIndex=meshtastic_channel,
519
- )
520
- meshtastic_logger.info(
521
- f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply to message {reply_id}"
522
- )
523
- meshtastic_logger.debug(
524
- f"sendTextReply returned packet: {sent_packet}"
525
- )
526
- except Exception as e:
744
+ # Queue the reply message
745
+ success = queue_message(
746
+ sendTextReply,
747
+ meshtastic_interface,
748
+ text=reply_message,
749
+ reply_id=reply_id,
750
+ channelIndex=meshtastic_channel,
751
+ description=f"Reply from {full_display_name} to message {reply_id}",
752
+ mapping_info=mapping_info,
753
+ )
754
+
755
+ if success:
756
+ # Get queue size to determine logging approach
757
+ queue_size = get_message_queue().get_queue_size()
758
+
759
+ if queue_size > 1:
760
+ meshtastic_logger.info(
761
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply (queued: {queue_size} messages)"
762
+ )
763
+ else:
764
+ meshtastic_logger.info(
765
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply"
766
+ )
767
+ else:
527
768
  meshtastic_logger.error(
528
- f"Error sending structured reply to Meshtastic: {e}"
769
+ "Failed to relay structured reply to Meshtastic"
529
770
  )
530
771
  return
531
772
  else:
532
773
  # Send as regular message (fallback for when no reply_id is available)
533
- try:
534
- meshtastic_logger.debug(
535
- f"Attempting to send text to Meshtastic: '{reply_message}' on channel {meshtastic_channel}"
536
- )
537
- sent_packet = meshtastic_interface.sendText(
538
- text=reply_message, channelIndex=meshtastic_channel
539
- )
540
- meshtastic_logger.info(
541
- f"Relaying Matrix reply from {full_display_name} to radio broadcast as regular message"
542
- )
543
- meshtastic_logger.debug(f"sendText returned packet: {sent_packet}")
544
- except Exception as e:
774
+ success = queue_message(
775
+ meshtastic_interface.sendText,
776
+ text=reply_message,
777
+ channelIndex=meshtastic_channel,
778
+ description=f"Reply from {full_display_name} (fallback to regular message)",
779
+ mapping_info=mapping_info,
780
+ )
781
+
782
+ if success:
783
+ # Get queue size to determine logging approach
784
+ queue_size = get_message_queue().get_queue_size()
785
+
786
+ if queue_size > 1:
787
+ meshtastic_logger.info(
788
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
789
+ )
790
+ else:
791
+ meshtastic_logger.info(
792
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast"
793
+ )
794
+ else:
545
795
  meshtastic_logger.error(
546
- f"Error sending reply message to Meshtastic: {e}"
796
+ "Failed to relay reply message to Meshtastic"
547
797
  )
548
798
  return
549
799
 
550
- # Store the reply in message map if storage is enabled
551
- if storage_enabled and sent_packet and hasattr(sent_packet, "id"):
552
- # Strip quoted lines from text before storing to prevent issues with reactions to replies
553
- cleaned_text = strip_quoted_lines(text)
554
- store_message_map(
555
- sent_packet.id,
556
- event.event_id,
557
- room.room_id,
558
- cleaned_text,
559
- meshtastic_meshnet=local_meshnet_name,
560
- )
561
- # Prune old messages if configured
562
- database_config = config.get("database", {})
563
- msg_map_config = database_config.get("msg_map", {})
564
- if not msg_map_config:
565
- db_config = config.get("db", {})
566
- legacy_msg_map_config = db_config.get("msg_map", {})
567
- if legacy_msg_map_config:
568
- msg_map_config = legacy_msg_map_config
569
- msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
570
- if msgs_to_keep > 0:
571
- prune_message_map(msgs_to_keep)
800
+ # Message mapping is now handled automatically by the queue system
572
801
 
573
802
  except Exception as e:
574
803
  meshtastic_logger.error(f"Error sending Matrix reply to Meshtastic: {e}")
@@ -582,6 +811,7 @@ async def handle_matrix_reply(
582
811
  room_config,
583
812
  storage_enabled,
584
813
  local_meshnet_name,
814
+ config,
585
815
  ):
586
816
  """
587
817
  Relays a Matrix reply to the corresponding Meshtastic message if a mapping exists.
@@ -607,7 +837,7 @@ async def handle_matrix_reply(
607
837
  full_display_name = await get_user_display_name(room, event)
608
838
 
609
839
  # Format the reply message
610
- reply_message = format_reply_message(full_display_name, text)
840
+ reply_message = format_reply_message(config, full_display_name, text)
611
841
 
612
842
  logger.info(
613
843
  f"Relaying Matrix reply from {full_display_name} to Meshtastic as reply to message {original_meshtastic_id}"
@@ -635,12 +865,14 @@ async def on_room_message(
635
865
  event: Union[RoomMessageText, RoomMessageNotice, ReactionEvent, RoomMessageEmote],
636
866
  ) -> None:
637
867
  """
638
- Handles incoming Matrix room messages, reactions, and replies, relaying them to Meshtastic as appropriate.
868
+ Asynchronously handles incoming Matrix room messages, reactions, and replies, relaying them to Meshtastic as appropriate.
639
869
 
640
- Processes events from Matrix rooms, including text messages, reactions, and replies. Relays supported messages to Meshtastic if broadcasting is enabled, applying message mapping for cross-referencing when reactions or replies are enabled. Prevents relaying of reactions to reactions and avoids processing messages from the bot itself or messages sent before the bot started. Integrates with plugins for command and message handling, and ensures that only supported messages are forwarded to Meshtastic.
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.
641
871
  """
642
872
  # Importing here to avoid circular imports and to keep logic consistent
643
873
  # Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
874
+ from mmrelay.message_queue import get_message_queue
875
+
644
876
  # That logic occurs inside matrix_relay if needed.
645
877
  full_display_name = "Unknown user"
646
878
  message_timestamp = event.server_timestamp
@@ -785,15 +1017,19 @@ async def on_room_message(
785
1017
  logger.debug(
786
1018
  f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
787
1019
  )
788
- try:
789
- sent_packet = meshtastic_interface.sendText(
790
- text=reaction_message, channelIndex=meshtastic_channel
791
- )
1020
+ success = queue_message(
1021
+ meshtastic_interface.sendText,
1022
+ text=reaction_message,
1023
+ channelIndex=meshtastic_channel,
1024
+ description=f"Remote reaction from {meshnet_name}",
1025
+ )
1026
+
1027
+ if success:
792
1028
  logger.debug(
793
- f"Remote reaction sendText returned packet: {sent_packet}"
1029
+ f"Queued remote reaction to Meshtastic: {reaction_message}"
794
1030
  )
795
- except Exception as e:
796
- logger.error(f"Error sending remote reaction to Meshtastic: {e}")
1031
+ else:
1032
+ logger.error("Failed to relay remote reaction to Meshtastic")
797
1033
  return
798
1034
  # We've relayed the remote reaction to our local mesh, so we're done.
799
1035
  return
@@ -824,8 +1060,7 @@ async def on_room_message(
824
1060
  full_display_name = display_name_response.displayname or event.sender
825
1061
 
826
1062
  # If not from a remote meshnet, proceed as normal to relay back to the originating meshnet
827
- short_display_name = full_display_name[:5]
828
- prefix = f"{short_display_name}[M]: "
1063
+ prefix = get_meshtastic_prefix(config, full_display_name)
829
1064
 
830
1065
  # Remove quoted lines so we don't bring in the original '>' lines from replies
831
1066
  meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
@@ -855,15 +1090,19 @@ async def on_room_message(
855
1090
  logger.debug(
856
1091
  f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
857
1092
  )
858
- try:
859
- sent_packet = meshtastic_interface.sendText(
860
- text=reaction_message, channelIndex=meshtastic_channel
861
- )
1093
+ success = queue_message(
1094
+ meshtastic_interface.sendText,
1095
+ text=reaction_message,
1096
+ channelIndex=meshtastic_channel,
1097
+ description=f"Local reaction from {full_display_name}",
1098
+ )
1099
+
1100
+ if success:
862
1101
  logger.debug(
863
- f"Local reaction sendText returned packet: {sent_packet}"
1102
+ f"Queued local reaction to Meshtastic: {reaction_message}"
864
1103
  )
865
- except Exception as e:
866
- logger.error(f"Error sending local reaction to Meshtastic: {e}")
1104
+ else:
1105
+ logger.error("Failed to relay local reaction to Meshtastic")
867
1106
  return
868
1107
  return
869
1108
 
@@ -877,6 +1116,7 @@ async def on_room_message(
877
1116
  room_config,
878
1117
  storage_enabled,
879
1118
  local_meshnet_name,
1119
+ config,
880
1120
  )
881
1121
  if reply_handled:
882
1122
  return
@@ -893,10 +1133,20 @@ async def on_room_message(
893
1133
  # If shortname is not available, derive it from the longname
894
1134
  if shortname is None:
895
1135
  shortname = longname[:3] if longname else "???"
896
- # Remove the original prefix "[longname/meshnet]: " to avoid double-tagging
897
- text = re.sub(rf"^\[{full_display_name}\]: ", "", text)
1136
+ # Remove the original prefix to avoid double-tagging
1137
+ # Get the prefix that would have been used for this message
1138
+ original_prefix = get_matrix_prefix(
1139
+ config, longname, shortname, meshnet_name
1140
+ )
1141
+ if original_prefix and text.startswith(original_prefix):
1142
+ text = text[len(original_prefix) :]
1143
+ logger.debug(
1144
+ f"Removed original prefix '{original_prefix}' from remote meshnet message"
1145
+ )
898
1146
  text = truncate_message(text)
899
- full_message = f"{shortname}/{short_meshnet_name}: {text}"
1147
+ # Use the configured prefix format for remote meshnet messages
1148
+ prefix = get_matrix_prefix(config, longname, shortname, short_meshnet_name)
1149
+ full_message = f"{prefix}{text}"
900
1150
  else:
901
1151
  # If this message is from our local meshnet (loopback), we ignore it
902
1152
  return
@@ -910,8 +1160,7 @@ async def on_room_message(
910
1160
  # Fallback to global display name if room-specific name is not available
911
1161
  display_name_response = await matrix_client.get_displayname(event.sender)
912
1162
  full_display_name = display_name_response.displayname or event.sender
913
- short_display_name = full_display_name[:5]
914
- prefix = f"{short_display_name}[M]: "
1163
+ prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
915
1164
  logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
916
1165
  full_message = f"{prefix}{text}"
917
1166
  text = truncate_message(text)
@@ -968,26 +1217,34 @@ async def on_room_message(
968
1217
  if not found_matching_plugin:
969
1218
  if config["meshtastic"]["broadcast_enabled"]:
970
1219
  portnum = event.source["content"].get("meshtastic_portnum")
971
- if portnum == "DETECTION_SENSOR_APP":
1220
+ if portnum == DETECTION_SENSOR_APP:
972
1221
  # If detection_sensor is enabled, forward this data as detection sensor data
973
1222
  if config["meshtastic"].get("detection_sensor", False):
974
- try:
975
- meshtastic_logger.debug(
976
- f"Attempting to send detection sensor data to Meshtastic: '{full_message}' on channel {meshtastic_channel}"
977
- )
978
- sent_packet = meshtastic_interface.sendData(
979
- data=full_message.encode("utf-8"),
980
- channelIndex=meshtastic_channel,
981
- portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
982
- )
983
- meshtastic_logger.debug(
984
- f"sendData returned packet: {sent_packet}"
985
- )
1223
+ success = queue_message(
1224
+ meshtastic_interface.sendData,
1225
+ data=full_message.encode("utf-8"),
1226
+ channelIndex=meshtastic_channel,
1227
+ portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
1228
+ description=f"Detection sensor data from {full_display_name}",
1229
+ )
1230
+
1231
+ if success:
1232
+ # Get queue size to determine logging approach
1233
+ queue_size = get_message_queue().get_queue_size()
1234
+
1235
+ if queue_size > 1:
1236
+ meshtastic_logger.info(
1237
+ f"Relaying detection sensor data from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
1238
+ )
1239
+ else:
1240
+ meshtastic_logger.info(
1241
+ f"Relaying detection sensor data from {full_display_name} to radio broadcast"
1242
+ )
986
1243
  # Note: Detection sensor messages are not stored in message_map because they are never replied to
987
1244
  # Only TEXT_MESSAGE_APP messages need to be stored for reaction handling
988
- except Exception as e:
1245
+ else:
989
1246
  meshtastic_logger.error(
990
- f"Error sending detection sensor data to Meshtastic: {e}"
1247
+ "Failed to relay detection sensor data to Meshtastic"
991
1248
  )
992
1249
  return
993
1250
  else:
@@ -995,53 +1252,47 @@ async def on_room_message(
995
1252
  f"Detection sensor packet received from {full_display_name}, but detection sensor processing is disabled."
996
1253
  )
997
1254
  else:
998
- meshtastic_logger.info(
999
- f"Relaying message from {full_display_name} to radio broadcast"
1000
- )
1255
+ # Regular text message - logging will be handled by queue success handler
1256
+ pass
1001
1257
 
1002
- try:
1003
- sent_packet = meshtastic_interface.sendText(
1004
- text=full_message, channelIndex=meshtastic_channel
1005
- )
1006
- if not sent_packet:
1007
- meshtastic_logger.warning(
1008
- "sendText returned None - message may not have been sent"
1009
- )
1010
- except Exception as e:
1011
- meshtastic_logger.error(f"Error sending message to Meshtastic: {e}")
1012
- import traceback
1258
+ # Create mapping info if storage is enabled
1259
+ mapping_info = None
1260
+ if storage_enabled:
1261
+ # Check database config for message map settings (preferred format)
1262
+ msgs_to_keep = _get_msgs_to_keep_config()
1013
1263
 
1014
- meshtastic_logger.error(f"Full traceback: {traceback.format_exc()}")
1015
- return
1016
- # Store message_map only if storage is enabled and only for TEXT_MESSAGE_APP
1017
- # (these are the only messages that can be replied to and thus need reaction handling)
1018
- if storage_enabled and sent_packet and hasattr(sent_packet, "id"):
1019
- # Strip quoted lines from text before storing to prevent issues with reactions to replies
1020
- cleaned_text = strip_quoted_lines(text)
1021
- store_message_map(
1022
- sent_packet.id,
1264
+ mapping_info = _create_mapping_info(
1023
1265
  event.event_id,
1024
1266
  room.room_id,
1025
- cleaned_text,
1026
- meshtastic_meshnet=local_meshnet_name,
1267
+ text,
1268
+ local_meshnet_name,
1269
+ msgs_to_keep,
1027
1270
  )
1028
- # Check database config for message map settings (preferred format)
1029
- database_config = config.get("database", {})
1030
- msg_map_config = database_config.get("msg_map", {})
1031
-
1032
- # If not found in database config, check legacy db config
1033
- if not msg_map_config:
1034
- db_config = config.get("db", {})
1035
- legacy_msg_map_config = db_config.get("msg_map", {})
1036
-
1037
- if legacy_msg_map_config:
1038
- msg_map_config = legacy_msg_map_config
1039
- logger.warning(
1040
- "Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
1041
- )
1042
- msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
1043
- if msgs_to_keep > 0:
1044
- prune_message_map(msgs_to_keep)
1271
+
1272
+ success = queue_message(
1273
+ meshtastic_interface.sendText,
1274
+ text=full_message,
1275
+ channelIndex=meshtastic_channel,
1276
+ description=f"Message from {full_display_name}",
1277
+ mapping_info=mapping_info,
1278
+ )
1279
+
1280
+ if success:
1281
+ # Get queue size to determine logging approach
1282
+ queue_size = get_message_queue().get_queue_size()
1283
+
1284
+ if queue_size > 1:
1285
+ meshtastic_logger.info(
1286
+ f"Relaying message from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
1287
+ )
1288
+ else:
1289
+ meshtastic_logger.info(
1290
+ f"Relaying message from {full_display_name} to radio broadcast"
1291
+ )
1292
+ else:
1293
+ meshtastic_logger.error("Failed to relay message to Meshtastic")
1294
+ return
1295
+ # Message mapping is now handled automatically by the queue system
1045
1296
  else:
1046
1297
  logger.debug(
1047
1298
  f"Broadcast not supported: Message from {full_display_name} dropped."