mmrelay 1.0.10__tar.gz → 1.0.11__tar.gz

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.

Files changed (39) hide show
  1. {mmrelay-1.0.10/src/mmrelay.egg-info → mmrelay-1.0.11}/PKG-INFO +3 -3
  2. {mmrelay-1.0.10 → mmrelay-1.0.11}/requirements.txt +2 -2
  3. {mmrelay-1.0.10 → mmrelay-1.0.11}/setup.cfg +3 -3
  4. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/__init__.py +1 -1
  5. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/db_utils.py +61 -9
  6. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/matrix_utils.py +321 -61
  7. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/meshtastic_utils.py +101 -10
  8. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/tools/sample_config.yaml +4 -1
  9. {mmrelay-1.0.10 → mmrelay-1.0.11/src/mmrelay.egg-info}/PKG-INFO +3 -3
  10. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay.egg-info/requires.txt +2 -2
  11. {mmrelay-1.0.10 → mmrelay-1.0.11}/LICENSE +0 -0
  12. {mmrelay-1.0.10 → mmrelay-1.0.11}/MANIFEST.in +0 -0
  13. {mmrelay-1.0.10 → mmrelay-1.0.11}/README.md +0 -0
  14. {mmrelay-1.0.10 → mmrelay-1.0.11}/pyproject.toml +0 -0
  15. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/cli.py +0 -0
  16. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/config.py +0 -0
  17. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/config_checker.py +0 -0
  18. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/log_utils.py +0 -0
  19. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/main.py +0 -0
  20. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugin_loader.py +0 -0
  21. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/__init__.py +0 -0
  22. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/base_plugin.py +0 -0
  23. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/debug_plugin.py +0 -0
  24. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/drop_plugin.py +0 -0
  25. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/health_plugin.py +0 -0
  26. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/help_plugin.py +0 -0
  27. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/map_plugin.py +0 -0
  28. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/mesh_relay_plugin.py +0 -0
  29. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/nodes_plugin.py +0 -0
  30. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/ping_plugin.py +0 -0
  31. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/telemetry_plugin.py +0 -0
  32. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/plugins/weather_plugin.py +0 -0
  33. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/setup_utils.py +0 -0
  34. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/tools/__init__.py +0 -0
  35. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay/tools/mmrelay.service +0 -0
  36. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay.egg-info/SOURCES.txt +0 -0
  37. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay.egg-info/dependency_links.txt +0 -0
  38. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay.egg-info/entry_points.txt +0 -0
  39. {mmrelay-1.0.10 → mmrelay-1.0.11}/src/mmrelay.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmrelay
3
- Version: 1.0.10
3
+ Version: 1.0.11
4
4
  Summary: Bridge between Meshtastic mesh networks and Matrix chat rooms
5
5
  Home-page: https://github.com/geoffwhittington/meshtastic-matrix-relay
6
6
  Author: Geoff Whittington, Jeremiah K., and contributors
@@ -18,8 +18,8 @@ Requires-Dist: meshtastic
18
18
  Requires-Dist: Pillow==11.2.1
19
19
  Requires-Dist: matrix-nio==0.25.2
20
20
  Requires-Dist: matplotlib==3.10.1
21
- Requires-Dist: requests==2.32.3
22
- Requires-Dist: markdown==3.8
21
+ Requires-Dist: requests==2.32.4
22
+ Requires-Dist: markdown==3.8.2
23
23
  Requires-Dist: haversine==2.9.0
24
24
  Requires-Dist: schedule==1.2.2
25
25
  Requires-Dist: platformdirs==4.3.8
@@ -2,8 +2,8 @@ meshtastic
2
2
  Pillow==11.2.1
3
3
  matrix-nio==0.25.2
4
4
  matplotlib==3.10.1
5
- requests==2.32.3
6
- markdown==3.8
5
+ requests==2.32.4
6
+ markdown==3.8.2
7
7
  haversine==2.9.0
8
8
  schedule==1.2.2
9
9
  platformdirs==4.3.8
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = mmrelay
3
- version = 1.0.10
3
+ version = 1.0.11
4
4
  author = Geoff Whittington, Jeremiah K., and contributors
5
5
  author_email = jeremiahk@gmx.com
6
6
  description = Bridge between Meshtastic mesh networks and Matrix chat rooms
@@ -26,8 +26,8 @@ install_requires =
26
26
  Pillow==11.2.1
27
27
  matrix-nio==0.25.2
28
28
  matplotlib==3.10.1
29
- requests==2.32.3
30
- markdown==3.8
29
+ requests==2.32.4
30
+ markdown==3.8.2
31
31
  haversine==2.9.0
32
32
  schedule==1.2.2
33
33
  platformdirs==4.3.8
@@ -14,4 +14,4 @@ else:
14
14
  __version__ = version("mmrelay")
15
15
  except PackageNotFoundError:
16
16
  # If all else fails, use hardcoded version
17
- __version__ = "1.0.10"
17
+ __version__ = "1.0.11"
@@ -8,17 +8,57 @@ from mmrelay.log_utils import get_logger
8
8
  # Global config variable that will be set from main.py
9
9
  config = None
10
10
 
11
+ # Cache for database path to avoid repeated logging and path resolution
12
+ _cached_db_path = None
13
+ _db_path_logged = False
14
+ _cached_config_hash = None
15
+
11
16
  logger = get_logger(name="db_utils")
12
17
 
13
18
 
19
+ def clear_db_path_cache():
20
+ """Clear the cached database path to force re-resolution on next call.
21
+
22
+ This is useful for testing or if the application supports runtime
23
+ configuration changes.
24
+ """
25
+ global _cached_db_path, _db_path_logged, _cached_config_hash
26
+ _cached_db_path = None
27
+ _db_path_logged = False
28
+ _cached_config_hash = None
29
+
30
+
14
31
  # Get the database path
15
32
  def get_db_path():
16
33
  """
17
- Returns the path to the SQLite database file.
18
- By default, uses the standard data directory (~/.mmrelay/data).
19
- Can be overridden by setting 'path' under 'database' in config.yaml.
34
+ Resolve and return the file path to the SQLite database, using configuration overrides if provided.
35
+
36
+ By default, returns the path to `meshtastic.sqlite` in the standard data directory (`~/.mmrelay/data`).
37
+ If a custom path is specified in the configuration under `database.path` (preferred) or `db.path` (legacy),
38
+ that path is used instead. The resolved path is cached for subsequent calls, and the directory is created
39
+ if it does not exist. Cache is automatically invalidated if the relevant configuration changes.
20
40
  """
21
- global config
41
+ global config, _cached_db_path, _db_path_logged, _cached_config_hash
42
+
43
+ # Create a hash of the relevant config sections to detect changes
44
+ current_config_hash = None
45
+ if config is not None:
46
+ # Hash only the database-related config sections
47
+ db_config = {
48
+ "database": config.get("database", {}),
49
+ "db": config.get("db", {}), # Legacy format
50
+ }
51
+ current_config_hash = hash(str(sorted(db_config.items())))
52
+
53
+ # Check if cache is valid (path exists and config hasn't changed)
54
+ if _cached_db_path is not None and current_config_hash == _cached_config_hash:
55
+ return _cached_db_path
56
+
57
+ # Config changed or first call - clear cache and re-resolve
58
+ if current_config_hash != _cached_config_hash:
59
+ _cached_db_path = None
60
+ _db_path_logged = False
61
+ _cached_config_hash = current_config_hash
22
62
 
23
63
  # Check if config is available
24
64
  if config is not None:
@@ -30,7 +70,12 @@ def get_db_path():
30
70
  db_dir = os.path.dirname(custom_path)
31
71
  if db_dir:
32
72
  os.makedirs(db_dir, exist_ok=True)
33
- logger.info(f"Using database path from config: {custom_path}")
73
+
74
+ # Cache the path and log only once
75
+ _cached_db_path = custom_path
76
+ if not _db_path_logged:
77
+ logger.info(f"Using database path from config: {custom_path}")
78
+ _db_path_logged = True
34
79
  return custom_path
35
80
 
36
81
  # Check legacy format (db section)
@@ -41,13 +86,20 @@ def get_db_path():
41
86
  db_dir = os.path.dirname(custom_path)
42
87
  if db_dir:
43
88
  os.makedirs(db_dir, exist_ok=True)
44
- logger.warning(
45
- "Using 'db.path' configuration (legacy). 'database.path' is now the preferred format and 'db.path' will be deprecated in a future version."
46
- )
89
+
90
+ # Cache the path and log only once
91
+ _cached_db_path = custom_path
92
+ if not _db_path_logged:
93
+ logger.warning(
94
+ "Using 'db.path' configuration (legacy). 'database.path' is now the preferred format and 'db.path' will be deprecated in a future version."
95
+ )
96
+ _db_path_logged = True
47
97
  return custom_path
48
98
 
49
99
  # Use the standard data directory
50
- return os.path.join(get_data_dir(), "meshtastic.sqlite")
100
+ default_path = os.path.join(get_data_dir(), "meshtastic.sqlite")
101
+ _cached_db_path = default_path
102
+ return default_path
51
103
 
52
104
 
53
105
  # Initialize SQLite database
@@ -31,6 +31,56 @@ from mmrelay.log_utils import get_logger
31
31
  # Do not import plugin_loader here to avoid circular imports
32
32
  from mmrelay.meshtastic_utils import connect_meshtastic
33
33
 
34
+
35
+ def get_interaction_settings(config):
36
+ """
37
+ Returns a dictionary indicating whether message reactions and replies are enabled based on the provided configuration.
38
+
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.
40
+ """
41
+ if config is None:
42
+ return {"reactions": False, "replies": False}
43
+
44
+ meshtastic_config = config.get("meshtastic", {})
45
+
46
+ # Check for new structured configuration first
47
+ if "message_interactions" in meshtastic_config:
48
+ interactions = meshtastic_config["message_interactions"]
49
+ return {
50
+ "reactions": interactions.get("reactions", False),
51
+ "replies": interactions.get("replies", False),
52
+ }
53
+
54
+ # Fall back to legacy relay_reactions setting
55
+ if "relay_reactions" in meshtastic_config:
56
+ enabled = meshtastic_config["relay_reactions"]
57
+ logger.warning(
58
+ "Configuration setting 'relay_reactions' is deprecated. "
59
+ "Please use 'message_interactions: {reactions: bool, replies: bool}' instead. "
60
+ "Legacy mode: enabling reactions only."
61
+ )
62
+ return {
63
+ "reactions": enabled,
64
+ "replies": False,
65
+ } # Only reactions for legacy compatibility
66
+
67
+ # Default to privacy-first (both disabled)
68
+ return {"reactions": False, "replies": False}
69
+
70
+
71
+ def message_storage_enabled(interactions):
72
+ """
73
+ Return True if message storage is required for enabled message interactions.
74
+
75
+ Parameters:
76
+ interactions (dict): Dictionary with 'reactions' and 'replies' boolean flags.
77
+
78
+ Returns:
79
+ bool: True if reactions or replies are enabled; otherwise, False.
80
+ """
81
+ return interactions["reactions"] or interactions["replies"]
82
+
83
+
34
84
  # Global config variable that will be set from config.py
35
85
  config = None
36
86
 
@@ -198,16 +248,29 @@ async def matrix_relay(
198
248
  meshtastic_text=None,
199
249
  emote=False,
200
250
  emoji=False,
251
+ reply_to_event_id=None,
201
252
  ):
202
253
  """
203
- Relay a message from Meshtastic to Matrix, optionally storing message maps.
204
-
205
- IMPORTANT CHANGE: Now, we only store message maps if `relay_reactions` is True.
206
- If `relay_reactions` is False, we skip storing to the message map entirely.
207
- This helps maintain privacy and prevents message_map usage unless needed.
208
-
209
- Additionally, if `msgs_to_keep` > 0, we prune the oldest messages after storing
210
- to prevent database bloat and maintain privacy.
254
+ Relays a message from Meshtastic to a Matrix room, supporting replies and message mapping for interactions.
255
+
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.
257
+
258
+ Parameters:
259
+ room_id (str): The Matrix room ID to send the message to.
260
+ message (str): The message content to relay.
261
+ longname (str): The sender's long display name from Meshtastic.
262
+ shortname (str): The sender's short display name from Meshtastic.
263
+ meshnet_name (str): The originating meshnet name.
264
+ portnum (int): The Meshtastic port number.
265
+ meshtastic_id (str, optional): The Meshtastic message ID for mapping.
266
+ meshtastic_replyId (str, optional): The Meshtastic message ID being replied to, if any.
267
+ meshtastic_text (str, optional): The original Meshtastic message text.
268
+ emote (bool, optional): Whether to send the message as an emote.
269
+ emoji (bool, optional): Whether the message is an emoji reaction.
270
+ reply_to_event_id (str, optional): The Matrix event ID being replied to, if sending a reply.
271
+
272
+ Returns:
273
+ None
211
274
  """
212
275
  global config
213
276
 
@@ -221,8 +284,9 @@ async def matrix_relay(
221
284
  logger.error("No configuration available. Cannot relay message to Matrix.")
222
285
  return
223
286
 
224
- # Retrieve relay_reactions configuration; default to False now if not specified.
225
- relay_reactions = config["meshtastic"].get("relay_reactions", False)
287
+ # Get interaction settings
288
+ interactions = get_interaction_settings(config)
289
+ storage_enabled = message_storage_enabled(interactions)
226
290
 
227
291
  # Retrieve db config for message_map pruning
228
292
  # Check database config for message map settings (preferred format)
@@ -263,6 +327,40 @@ async def matrix_relay(
263
327
  if emoji:
264
328
  content["meshtastic_emoji"] = 1
265
329
 
330
+ # Add Matrix reply formatting if this is a reply
331
+ if reply_to_event_id:
332
+ content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
333
+ # For Matrix replies, we need to format the body with quoted content
334
+ # Get the original message details for proper quoting
335
+ try:
336
+ orig = get_message_map_by_matrix_event_id(reply_to_event_id)
337
+ if orig:
338
+ # orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
339
+ _, _, original_text, original_meshnet = orig
340
+
341
+ # Use the relay bot's user ID for attribution (this is correct for relay messages)
342
+ bot_user_id = matrix_client.user_id
343
+ original_sender_display = f"{longname}/{original_meshnet}"
344
+
345
+ # Create the quoted reply format
346
+ quoted_text = f"> <@{bot_user_id}> [{original_sender_display}]: {original_text}"
347
+ content["body"] = f"{quoted_text}\n\n{message}"
348
+ content["format"] = "org.matrix.custom.html"
349
+
350
+ # Create formatted HTML version with better readability
351
+ reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
352
+ bot_link = f"https://matrix.to/#/@{bot_user_id}"
353
+ 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}'
354
+ content["formatted_body"] = (
355
+ f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{message}"
356
+ )
357
+ else:
358
+ logger.warning(
359
+ f"Could not find original message for reply_to_event_id: {reply_to_event_id}"
360
+ )
361
+ except Exception as e:
362
+ logger.error(f"Error formatting Matrix reply: {e}")
363
+
266
364
  try:
267
365
  # Ensure matrix_client is not None
268
366
  if not matrix_client:
@@ -292,10 +390,10 @@ async def matrix_relay(
292
390
  logger.error(f"Error sending message to Matrix room {room_id}: {e}")
293
391
  return
294
392
 
295
- # Only store message map if relay_reactions is True and meshtastic_id is present and not an emote.
296
- # If relay_reactions is False, we skip storing entirely.
393
+ # Only store message map if any interactions are enabled and conditions are met
394
+ # This enables reactions and/or replies functionality based on configuration
297
395
  if (
298
- relay_reactions
396
+ storage_enabled
299
397
  and meshtastic_id is not None
300
398
  and not emote
301
399
  and hasattr(response, "event_id")
@@ -337,30 +435,190 @@ def truncate_message(text, max_bytes=227):
337
435
 
338
436
  def strip_quoted_lines(text: str) -> str:
339
437
  """
340
- Remove lines that begin with '>' to avoid including
341
- the original quoted part of a Matrix reply in reaction text.
438
+ Removes lines starting with '>' from the input text.
439
+
440
+ This is typically used to exclude quoted content from Matrix replies, such as when processing reaction text.
342
441
  """
343
442
  lines = text.splitlines()
344
443
  filtered = [line for line in lines if not line.strip().startswith(">")]
345
444
  return " ".join(filtered).strip()
346
445
 
347
446
 
447
+ async def get_user_display_name(room, event):
448
+ """
449
+ Retrieve the display name of a Matrix user, preferring the room-specific name if available.
450
+
451
+ Returns:
452
+ str: The user's display name, or their Matrix ID if no display name is set.
453
+ """
454
+ room_display_name = room.user_name(event.sender)
455
+ if room_display_name:
456
+ return room_display_name
457
+
458
+ display_name_response = await matrix_client.get_displayname(event.sender)
459
+ return display_name_response.displayname or event.sender
460
+
461
+
462
+ def format_reply_message(full_display_name, text):
463
+ """
464
+ Format a reply message by prefixing a truncated display name and removing quoted lines.
465
+
466
+ The resulting message is prefixed with the first five characters of the user's display name followed by "[M]: ", has quoted lines removed, and is truncated to fit within the allowed message length.
467
+
468
+ Parameters:
469
+ full_display_name (str): The user's full display name to be truncated for the prefix.
470
+ text (str): The reply text, possibly containing quoted lines.
471
+
472
+ Returns:
473
+ str: The formatted and truncated reply message.
474
+ """
475
+ short_display_name = full_display_name[:5]
476
+ prefix = f"{short_display_name}[M]: "
477
+
478
+ # Strip quoted content from the reply text
479
+ clean_text = strip_quoted_lines(text)
480
+ reply_message = f"{prefix}{clean_text}"
481
+ return truncate_message(reply_message)
482
+
483
+
484
+ async def send_reply_to_meshtastic(
485
+ reply_message,
486
+ full_display_name,
487
+ room_config,
488
+ room,
489
+ event,
490
+ text,
491
+ storage_enabled,
492
+ local_meshnet_name,
493
+ reply_id=None,
494
+ ):
495
+ """
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.
499
+
500
+ Args:
501
+ reply_id: If provided, sends as a structured Meshtastic reply to this message ID
502
+ """
503
+ meshtastic_interface = connect_meshtastic()
504
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
505
+ from mmrelay.meshtastic_utils import sendTextReply
506
+
507
+ meshtastic_channel = room_config["meshtastic_channel"]
508
+
509
+ if config["meshtastic"]["broadcast_enabled"]:
510
+ try:
511
+ if reply_id is not None:
512
+ # Send as a structured reply using our custom function
513
+ sent_packet = sendTextReply(
514
+ meshtastic_interface,
515
+ text=reply_message,
516
+ reply_id=reply_id,
517
+ channelIndex=meshtastic_channel,
518
+ )
519
+ meshtastic_logger.info(
520
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply to message {reply_id}"
521
+ )
522
+ else:
523
+ # Send as regular message (fallback for when no reply_id is available)
524
+ sent_packet = meshtastic_interface.sendText(
525
+ text=reply_message, channelIndex=meshtastic_channel
526
+ )
527
+ meshtastic_logger.info(
528
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as regular message"
529
+ )
530
+
531
+ # Store the reply in message map if storage is enabled
532
+ if storage_enabled and sent_packet and hasattr(sent_packet, "id"):
533
+ # Strip quoted lines from text before storing to prevent issues with reactions to replies
534
+ cleaned_text = strip_quoted_lines(text)
535
+ store_message_map(
536
+ sent_packet.id,
537
+ event.event_id,
538
+ room.room_id,
539
+ cleaned_text,
540
+ meshtastic_meshnet=local_meshnet_name,
541
+ )
542
+ # Prune old messages if configured
543
+ database_config = config.get("database", {})
544
+ msg_map_config = database_config.get("msg_map", {})
545
+ if not msg_map_config:
546
+ db_config = config.get("db", {})
547
+ legacy_msg_map_config = db_config.get("msg_map", {})
548
+ if legacy_msg_map_config:
549
+ msg_map_config = legacy_msg_map_config
550
+ msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
551
+ if msgs_to_keep > 0:
552
+ prune_message_map(msgs_to_keep)
553
+
554
+ except Exception as e:
555
+ meshtastic_logger.error(f"Error sending Matrix reply to Meshtastic: {e}")
556
+
557
+
558
+ async def handle_matrix_reply(
559
+ room,
560
+ event,
561
+ reply_to_event_id,
562
+ text,
563
+ room_config,
564
+ storage_enabled,
565
+ local_meshnet_name,
566
+ ):
567
+ """
568
+ Relays a Matrix reply to the corresponding Meshtastic message if a mapping exists.
569
+
570
+ Looks up the original Meshtastic message using the Matrix event ID being replied to. If found, formats and sends the reply to Meshtastic, preserving conversational context. Returns True if the reply was successfully handled; otherwise, returns False to allow normal message processing.
571
+
572
+ Returns:
573
+ bool: True if the reply was relayed to Meshtastic, False otherwise.
574
+ """
575
+ # Look up the original message in the message map
576
+ orig = get_message_map_by_matrix_event_id(reply_to_event_id)
577
+ if not orig:
578
+ logger.debug(
579
+ f"Original message for Matrix reply not found in DB: {reply_to_event_id}"
580
+ )
581
+ return False # Continue processing as normal message if original not found
582
+
583
+ # Extract the original meshtastic_id to use as reply_id
584
+ # orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
585
+ original_meshtastic_id = orig[0]
586
+
587
+ # Get user display name
588
+ full_display_name = await get_user_display_name(room, event)
589
+
590
+ # Format the reply message
591
+ reply_message = format_reply_message(full_display_name, text)
592
+
593
+ logger.info(
594
+ f"Relaying Matrix reply from {full_display_name} to Meshtastic as reply to message {original_meshtastic_id}"
595
+ )
596
+
597
+ # Send the reply to Meshtastic with the original message ID as reply_id
598
+ await send_reply_to_meshtastic(
599
+ reply_message,
600
+ full_display_name,
601
+ room_config,
602
+ room,
603
+ event,
604
+ text,
605
+ storage_enabled,
606
+ local_meshnet_name,
607
+ reply_id=original_meshtastic_id,
608
+ )
609
+
610
+ return True # Reply was handled, stop further processing
611
+
612
+
348
613
  # Callback for new messages in Matrix room
349
614
  async def on_room_message(
350
615
  room: MatrixRoom,
351
616
  event: Union[RoomMessageText, RoomMessageNotice, ReactionEvent, RoomMessageEmote],
352
617
  ) -> None:
353
618
  """
354
- Handle new messages and reactions in Matrix. For reactions, we ensure that when relaying back
355
- to Meshtastic, we always apply our local meshnet_name to outgoing events.
356
-
357
- We must be careful not to relay reactions to reactions (reaction-chains),
358
- especially remote reactions that got relayed into the room as m.emote events,
359
- as we do not store them in the database. If we can't find the original message in the DB,
360
- it likely means it's a reaction to a reaction, and we stop there.
619
+ Handles incoming Matrix room messages, reactions, and replies, relaying them to Meshtastic as appropriate.
361
620
 
362
- Additionally, we only deal with message_map storage (and thus reaction linking)
363
- if relay_reactions is True. If it's False, none of these mappings are stored or used.
621
+ 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.
364
622
  """
365
623
  # Importing here to avoid circular imports and to keep logic consistent
366
624
  # Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
@@ -403,8 +661,9 @@ async def on_room_message(
403
661
  logger.error("No configuration available. Cannot process Matrix message.")
404
662
  return
405
663
 
406
- # Retrieve relay_reactions option from config, now defaulting to False
407
- relay_reactions = config["meshtastic"].get("relay_reactions", False)
664
+ # Get interaction settings
665
+ interactions = get_interaction_settings(config)
666
+ storage_enabled = message_storage_enabled(interactions)
408
667
 
409
668
  # Check if this is a Matrix ReactionEvent (usually m.reaction)
410
669
  if isinstance(event, ReactionEvent):
@@ -441,17 +700,26 @@ async def on_room_message(
441
700
  if suppress:
442
701
  return
443
702
 
444
- # If this is a reaction and relay_reactions is False, do nothing
445
- if is_reaction and not relay_reactions:
703
+ # If this is a reaction and reactions are disabled, do nothing
704
+ if is_reaction and not interactions["reactions"]:
446
705
  logger.debug(
447
- "Reaction event encountered but relay_reactions is disabled. Doing nothing."
706
+ "Reaction event encountered but reactions are disabled. Doing nothing."
448
707
  )
449
708
  return
450
709
 
451
710
  local_meshnet_name = config["meshtastic"]["meshnet_name"]
452
711
 
453
- # If this is a reaction and relay_reactions is True, attempt to relay it
454
- if is_reaction and relay_reactions:
712
+ # Check if this is a Matrix reply (not a reaction)
713
+ is_reply = False
714
+ reply_to_event_id = None
715
+ if not is_reaction and relates_to and "m.in_reply_to" in relates_to:
716
+ reply_to_event_id = relates_to["m.in_reply_to"].get("event_id")
717
+ if reply_to_event_id:
718
+ is_reply = True
719
+ logger.debug(f"Processing Matrix reply to event: {reply_to_event_id}")
720
+
721
+ # If this is a reaction and reactions are enabled, attempt to relay it
722
+ if is_reaction and interactions["reactions"]:
455
723
  # Check if we need to relay a reaction from a remote meshnet to our local meshnet.
456
724
  # If meshnet_name != local_meshnet_name and meshtastic_replyId is present and this is an emote,
457
725
  # it's a remote reaction that needs to be forwarded as a text message describing the reaction.
@@ -566,6 +834,20 @@ async def on_room_message(
566
834
  )
567
835
  return
568
836
 
837
+ # Handle Matrix replies to Meshtastic messages (only if replies are enabled)
838
+ if is_reply and reply_to_event_id and interactions["replies"]:
839
+ reply_handled = await handle_matrix_reply(
840
+ room,
841
+ event,
842
+ reply_to_event_id,
843
+ text,
844
+ room_config,
845
+ storage_enabled,
846
+ local_meshnet_name,
847
+ )
848
+ if reply_handled:
849
+ return
850
+
569
851
  # For Matrix->Mesh messages from a remote meshnet, rewrite the message format
570
852
  if longname and meshnet_name:
571
853
  # Always include the meshnet_name in the full display name.
@@ -661,33 +943,8 @@ async def on_room_message(
661
943
  channelIndex=meshtastic_channel,
662
944
  portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
663
945
  )
664
- # If relay_reactions is True, we store the message map for these messages as well.
665
- # If False, skip storing.
666
- if relay_reactions and sent_packet and hasattr(sent_packet, "id"):
667
- store_message_map(
668
- sent_packet.id,
669
- event.event_id,
670
- room.room_id,
671
- text,
672
- meshtastic_meshnet=local_meshnet_name,
673
- )
674
- # Check database config for message map settings (preferred format)
675
- database_config = config.get("database", {})
676
- msg_map_config = database_config.get("msg_map", {})
677
-
678
- # If not found in database config, check legacy db config
679
- if not msg_map_config:
680
- db_config = config.get("db", {})
681
- legacy_msg_map_config = db_config.get("msg_map", {})
682
-
683
- if legacy_msg_map_config:
684
- msg_map_config = legacy_msg_map_config
685
- logger.warning(
686
- "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."
687
- )
688
- msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
689
- if msgs_to_keep > 0:
690
- prune_message_map(msgs_to_keep)
946
+ # Note: Detection sensor messages are not stored in message_map because they are never replied to
947
+ # Only TEXT_MESSAGE_APP messages need to be stored for reaction handling
691
948
  else:
692
949
  meshtastic_logger.debug(
693
950
  f"Detection sensor packet received from {full_display_name}, but detection sensor processing is disabled."
@@ -704,13 +961,16 @@ async def on_room_message(
704
961
  except Exception as e:
705
962
  meshtastic_logger.error(f"Error sending message to Meshtastic: {e}")
706
963
  return
707
- # Store message_map only if relay_reactions is True
708
- if relay_reactions and sent_packet and hasattr(sent_packet, "id"):
964
+ # Store message_map only if storage is enabled and only for TEXT_MESSAGE_APP
965
+ # (these are the only messages that can be replied to and thus need reaction handling)
966
+ if storage_enabled and sent_packet and hasattr(sent_packet, "id"):
967
+ # Strip quoted lines from text before storing to prevent issues with reactions to replies
968
+ cleaned_text = strip_quoted_lines(text)
709
969
  store_message_map(
710
970
  sent_packet.id,
711
971
  event.event_id,
712
972
  room.room_id,
713
- text,
973
+ cleaned_text,
714
974
  meshtastic_meshnet=local_meshnet_name,
715
975
  )
716
976
  # Check database config for message map settings (preferred format)
@@ -12,6 +12,7 @@ import meshtastic.tcp_interface
12
12
  import serial # For serial port exceptions
13
13
  import serial.tools.list_ports # Import serial tools for port listing
14
14
  from bleak.exc import BleakDBusError, BleakError
15
+ from meshtastic.protobuf import mesh_pb2, portnums_pb2
15
16
  from pubsub import pub
16
17
 
17
18
  from mmrelay.db_utils import (
@@ -322,9 +323,9 @@ async def reconnect():
322
323
 
323
324
  def on_meshtastic_message(packet, interface):
324
325
  """
325
- Handle incoming Meshtastic messages. For reaction messages, if relay_reactions is False,
326
- we do not store message maps and thus won't be able to relay reactions back to Matrix.
327
- If relay_reactions is True, message maps are stored inside matrix_relay().
326
+ Processes incoming Meshtastic messages and relays them to Matrix rooms or plugins based on message type and interaction settings.
327
+
328
+ Handles reactions and replies by relaying them to Matrix if enabled in the interaction settings. Normal text messages are relayed to all mapped Matrix rooms unless handled by a plugin or directed to the relay node. Non-text messages are passed to plugins for processing. Filters out messages from unmapped channels or disabled detection sensors, and ensures sender information is retrieved or stored as needed.
328
329
  """
329
330
  global config, matrix_rooms
330
331
 
@@ -341,15 +342,24 @@ def on_meshtastic_message(packet, interface):
341
342
  logger.error("No configuration available. Cannot process Meshtastic message.")
342
343
  return
343
344
 
344
- # Apply reaction filtering based on config
345
- relay_reactions = config["meshtastic"].get("relay_reactions", False)
345
+ # Import the configuration helpers
346
+ from mmrelay.matrix_utils import get_interaction_settings, message_storage_enabled
347
+
348
+ # Get interaction settings
349
+ interactions = get_interaction_settings(config)
350
+ message_storage_enabled(interactions)
346
351
 
347
- # If relay_reactions is False, filter out reaction/tapback packets to avoid complexity
352
+ # Filter packets based on interaction settings
348
353
  if packet.get("decoded", {}).get("portnum") == "TEXT_MESSAGE_APP":
349
354
  decoded = packet.get("decoded", {})
350
- if not relay_reactions and ("emoji" in decoded or "replyId" in decoded):
355
+ # Filter out reactions if reactions are disabled
356
+ if (
357
+ not interactions["reactions"]
358
+ and "emoji" in decoded
359
+ and decoded.get("emoji") == 1
360
+ ):
351
361
  logger.debug(
352
- "Filtered out reaction/tapback packet due to relay_reactions=false."
362
+ "Filtered out reaction packet due to reactions being disabled."
353
363
  )
354
364
  return
355
365
 
@@ -391,8 +401,8 @@ def on_meshtastic_message(packet, interface):
391
401
  meshnet_name = config["meshtastic"]["meshnet_name"]
392
402
 
393
403
  # Reaction handling (Meshtastic -> Matrix)
394
- # If replyId and emoji_flag are present and relay_reactions is True, we relay as text reactions in Matrix
395
- if replyId and emoji_flag and relay_reactions:
404
+ # If replyId and emoji_flag are present and reactions are enabled, we relay as text reactions in Matrix
405
+ if replyId and emoji_flag and interactions["reactions"]:
396
406
  longname = get_longname(sender) or str(sender)
397
407
  shortname = get_shortname(sender) or str(sender)
398
408
  orig = get_message_map_by_meshtastic_id(replyId)
@@ -432,6 +442,42 @@ def on_meshtastic_message(packet, interface):
432
442
  logger.debug("Original message for reaction not found in DB.")
433
443
  return
434
444
 
445
+ # Reply handling (Meshtastic -> Matrix)
446
+ # If replyId is present but emoji is not (or not 1), this is a reply
447
+ if replyId and not emoji_flag and interactions["replies"]:
448
+ longname = get_longname(sender) or str(sender)
449
+ shortname = get_shortname(sender) or str(sender)
450
+ orig = get_message_map_by_meshtastic_id(replyId)
451
+ if orig:
452
+ # orig = (matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
453
+ matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet = orig
454
+
455
+ # Format the reply message for Matrix
456
+ full_display_name = f"{longname}/{meshnet_name}"
457
+ formatted_message = f"[{full_display_name}]: {text}"
458
+
459
+ logger.info(f"Relaying Meshtastic reply from {longname} to Matrix")
460
+
461
+ # Relay the reply to Matrix with proper reply formatting
462
+ asyncio.run_coroutine_threadsafe(
463
+ matrix_relay(
464
+ matrix_room_id,
465
+ formatted_message,
466
+ longname,
467
+ shortname,
468
+ meshnet_name,
469
+ decoded.get("portnum"),
470
+ meshtastic_id=packet.get("id"),
471
+ meshtastic_replyId=replyId,
472
+ meshtastic_text=text,
473
+ reply_to_event_id=matrix_event_id,
474
+ ),
475
+ loop=loop,
476
+ )
477
+ else:
478
+ logger.debug("Original message for reply not found in DB.")
479
+ return
480
+
435
481
  # Normal text messages or detection sensor messages
436
482
  if text:
437
483
  # Determine the channel for this message
@@ -615,6 +661,51 @@ async def check_connection():
615
661
  await asyncio.sleep(30) # Check connection every 30 seconds
616
662
 
617
663
 
664
+ def sendTextReply(
665
+ interface,
666
+ text: str,
667
+ reply_id: int,
668
+ destinationId=meshtastic.BROADCAST_ADDR,
669
+ wantAck: bool = False,
670
+ channelIndex: int = 0,
671
+ ):
672
+ """
673
+ Send a text message as a reply to a previous message.
674
+
675
+ This function creates a proper Meshtastic reply by setting the reply_id field
676
+ in the Data protobuf message, which the standard sendText() method doesn't support.
677
+
678
+ Args:
679
+ interface: The Meshtastic interface to send through
680
+ text: The text message to send
681
+ reply_id: The ID of the message this is replying to
682
+ destinationId: Where to send the message (default: broadcast)
683
+ wantAck: Whether to request acknowledgment
684
+ channelIndex: Which channel to send on
685
+
686
+ Returns:
687
+ The sent packet with populated ID field
688
+ """
689
+ logger.debug(f"Sending text reply: '{text}' replying to message ID {reply_id}")
690
+
691
+ # Create the Data protobuf message with reply_id set
692
+ data_msg = mesh_pb2.Data()
693
+ data_msg.portnum = portnums_pb2.PortNum.TEXT_MESSAGE_APP
694
+ data_msg.payload = text.encode("utf-8")
695
+ data_msg.reply_id = reply_id
696
+
697
+ # Create the MeshPacket
698
+ mesh_packet = mesh_pb2.MeshPacket()
699
+ mesh_packet.channel = channelIndex
700
+ mesh_packet.decoded.CopyFrom(data_msg)
701
+ mesh_packet.id = interface._generatePacketId()
702
+
703
+ # Send the packet using the existing infrastructure
704
+ return interface._sendPacket(
705
+ mesh_packet, destinationId=destinationId, wantAck=wantAck
706
+ )
707
+
708
+
618
709
  if __name__ == "__main__":
619
710
  # If running this standalone (normally the main.py does the loop), just try connecting and run forever.
620
711
  meshtastic_client = connect_meshtastic()
@@ -18,7 +18,10 @@ meshtastic:
18
18
  broadcast_enabled: true # Must be set to true to enable Matrix to Meshtastic messages
19
19
  detection_sensor: true # Must be set to true to forward messages of Meshtastic's detection sensor module
20
20
  plugin_response_delay: 3 # Default response delay in seconds for plugins that respond on the mesh;
21
- relay_reactions: true # Defaults to false, set to true to enable relay reactions between platforms
21
+ message_interactions: # Configure reactions and replies (both require message storage in database)
22
+ reactions: false # Enable reaction relaying between platforms
23
+ replies: false # Enable reply relaying between platforms
24
+ # Note: Legacy 'relay_reactions' setting is deprecated but still supported
22
25
 
23
26
  logging:
24
27
  level: info
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmrelay
3
- Version: 1.0.10
3
+ Version: 1.0.11
4
4
  Summary: Bridge between Meshtastic mesh networks and Matrix chat rooms
5
5
  Home-page: https://github.com/geoffwhittington/meshtastic-matrix-relay
6
6
  Author: Geoff Whittington, Jeremiah K., and contributors
@@ -18,8 +18,8 @@ Requires-Dist: meshtastic
18
18
  Requires-Dist: Pillow==11.2.1
19
19
  Requires-Dist: matrix-nio==0.25.2
20
20
  Requires-Dist: matplotlib==3.10.1
21
- Requires-Dist: requests==2.32.3
22
- Requires-Dist: markdown==3.8
21
+ Requires-Dist: requests==2.32.4
22
+ Requires-Dist: markdown==3.8.2
23
23
  Requires-Dist: haversine==2.9.0
24
24
  Requires-Dist: schedule==1.2.2
25
25
  Requires-Dist: platformdirs==4.3.8
@@ -2,8 +2,8 @@ meshtastic
2
2
  Pillow==11.2.1
3
3
  matrix-nio==0.25.2
4
4
  matplotlib==3.10.1
5
- requests==2.32.3
6
- markdown==3.8
5
+ requests==2.32.4
6
+ markdown==3.8.2
7
7
  haversine==2.9.0
8
8
  schedule==1.2.2
9
9
  platformdirs==4.3.8
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes