mmrelay 1.2.6__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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3091 @@
1
+ import asyncio
2
+ import getpass
3
+ import html
4
+ import importlib
5
+ import io
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import ssl
11
+ import sys
12
+ import time
13
+ from typing import Any, Dict, Optional, Union
14
+ from urllib.parse import urlparse
15
+
16
+ from nio import (
17
+ AsyncClient,
18
+ AsyncClientConfig,
19
+ DiscoveryInfoError,
20
+ DiscoveryInfoResponse,
21
+ MatrixRoom,
22
+ MegolmEvent,
23
+ ReactionEvent,
24
+ RoomMessageEmote,
25
+ RoomMessageNotice,
26
+ RoomMessageText,
27
+ UploadResponse,
28
+ )
29
+ from nio.events.room_events import RoomMemberEvent
30
+ from PIL import Image
31
+
32
+ # Import nio exception types with error handling for test environments
33
+ try:
34
+ from nio.exceptions import LocalProtocolError as NioLocalProtocolError
35
+ from nio.exceptions import LocalTransportError as NioLocalTransportError
36
+ from nio.exceptions import RemoteProtocolError as NioRemoteProtocolError
37
+ from nio.exceptions import RemoteTransportError as NioRemoteTransportError
38
+ from nio.responses import ErrorResponse as NioErrorResponse
39
+ from nio.responses import LoginError as NioLoginError
40
+ from nio.responses import LogoutError as NioLogoutError
41
+ except ImportError:
42
+ # Fallback for test environments where nio imports might fail
43
+ NioLoginError = Exception
44
+ NioLogoutError = Exception
45
+ NioErrorResponse = Exception
46
+ NioLocalProtocolError = Exception
47
+ NioRemoteProtocolError = Exception
48
+ NioLocalTransportError = Exception
49
+ NioRemoteTransportError = Exception
50
+
51
+ from mmrelay.cli_utils import (
52
+ _create_ssl_context,
53
+ msg_require_auth_login,
54
+ msg_retry_auth_login,
55
+ )
56
+ from mmrelay.config import (
57
+ get_base_dir,
58
+ get_e2ee_store_dir,
59
+ get_meshtastic_config_value,
60
+ load_credentials,
61
+ save_credentials,
62
+ )
63
+ from mmrelay.constants.app import WINDOWS_PLATFORM
64
+ from mmrelay.constants.config import (
65
+ CONFIG_SECTION_MATRIX,
66
+ DEFAULT_BROADCAST_ENABLED,
67
+ DEFAULT_DETECTION_SENSOR,
68
+ E2EE_KEY_SHARING_DELAY_SECONDS,
69
+ )
70
+ from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
71
+ from mmrelay.constants.formats import (
72
+ DEFAULT_MATRIX_PREFIX,
73
+ DEFAULT_MESHTASTIC_PREFIX,
74
+ DETECTION_SENSOR_APP,
75
+ )
76
+ from mmrelay.constants.messages import (
77
+ DEFAULT_MESSAGE_TRUNCATE_BYTES,
78
+ DISPLAY_NAME_DEFAULT_LENGTH,
79
+ MAX_TRUNCATION_LENGTH,
80
+ MESHNET_NAME_ABBREVIATION_LENGTH,
81
+ MESSAGE_PREVIEW_LENGTH,
82
+ SHORTNAME_FALLBACK_LENGTH,
83
+ TRUNCATION_LOG_LIMIT,
84
+ )
85
+ from mmrelay.constants.network import (
86
+ MATRIX_EARLY_SYNC_TIMEOUT,
87
+ MATRIX_LOGIN_TIMEOUT,
88
+ MATRIX_ROOM_SEND_TIMEOUT,
89
+ MATRIX_SYNC_OPERATION_TIMEOUT,
90
+ MILLISECONDS_PER_SECOND,
91
+ )
92
+ from mmrelay.db_utils import (
93
+ async_prune_message_map,
94
+ async_store_message_map,
95
+ get_message_map_by_matrix_event_id,
96
+ )
97
+ from mmrelay.log_utils import get_logger
98
+
99
+ # Do not import plugin_loader here to avoid circular imports
100
+ from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
101
+ from mmrelay.message_queue import get_message_queue, queue_message
102
+
103
+ logger = get_logger(name="Matrix")
104
+
105
+
106
+ def _is_room_alias(value: Any) -> bool:
107
+ """Return True when value looks like a Matrix room alias (string starting with '#')."""
108
+
109
+ return isinstance(value, str) and value.startswith("#")
110
+
111
+
112
+ def _iter_room_alias_entries(mapping):
113
+ """
114
+ Yield (alias_or_id, setter) pairs for entries in a Matrix room mapping.
115
+
116
+ Each yielded tuple contains:
117
+ - alias_or_id (str): the room alias or room ID found in the entry (may be an alias starting with '#' or a canonical room ID). If a dict entry has no `"id"` key, an empty string is yielded.
118
+ - setter (callable): a function accepting a single argument `new_id` which updates the underlying mapping in-place to replace the alias with the resolved room ID.
119
+
120
+ Supports two mapping shapes:
121
+ - list: items may be strings (alias/ID) or dicts with an `"id"` key.
122
+ - dict: values may be strings (alias/ID) or dicts with an `"id"` key.
123
+
124
+ The setter updates the original collection (list element or dict value) so callers can resolve aliases and persist resolved IDs back into the provided mapping.
125
+ """
126
+
127
+ if isinstance(mapping, list):
128
+ for index, entry in enumerate(mapping):
129
+ if isinstance(entry, dict):
130
+ yield entry.get(
131
+ "id", ""
132
+ ), lambda new_id, target=entry: target.__setitem__("id", new_id)
133
+ else:
134
+ yield entry, lambda new_id, idx=index, collection=mapping: collection.__setitem__(
135
+ idx, new_id
136
+ )
137
+ elif isinstance(mapping, dict):
138
+ for key, entry in list(mapping.items()):
139
+ if isinstance(entry, dict):
140
+ yield entry.get(
141
+ "id", ""
142
+ ), lambda new_id, target=entry: target.__setitem__("id", new_id)
143
+ else:
144
+ yield entry, lambda new_id, target_key=key, collection=mapping: collection.__setitem__(
145
+ target_key, new_id
146
+ )
147
+
148
+
149
+ async def _resolve_aliases_in_mapping(mapping, resolver):
150
+ """
151
+ Resolve Matrix room alias entries found in a mapping (list or dict) by calling an async resolver and replacing aliases with resolved room IDs in-place.
152
+
153
+ This function iterates entries produced by _iter_room_alias_entries(mapping). For each entry whose key/value looks like a Matrix room alias (a string starting with '#'), it awaits the provided resolver coroutine with the alias; if the resolver returns a truthy room ID, the corresponding entry in the original mapping is updated via the entry's setter. If mapping is not a list or dict, the function logs a warning and returns without modifying anything.
154
+
155
+ Parameters:
156
+ mapping (list|dict): A mapping of Matrix rooms where some entries may be aliases (e.g., "#room:example.org").
157
+ resolver (Callable[[str], Awaitable[Optional[str]]]): Async callable that accepts an alias and returns a resolved room ID (or falsy on failure).
158
+
159
+ Returns:
160
+ None
161
+ """
162
+
163
+ if not isinstance(mapping, (list, dict)):
164
+ logger.warning(
165
+ "matrix_rooms is expected to be a list or dict, got %s",
166
+ type(mapping).__name__,
167
+ )
168
+ return
169
+
170
+ for alias, setter in _iter_room_alias_entries(mapping):
171
+ if _is_room_alias(alias):
172
+ resolved_id = await resolver(alias)
173
+ if resolved_id:
174
+ setter(resolved_id)
175
+
176
+
177
+ def _update_room_id_in_mapping(mapping, alias, resolved_id) -> bool:
178
+ """
179
+ Replace a room alias with its resolved room ID in a mapping.
180
+
181
+ Parameters:
182
+ mapping (list|dict): A matrix_rooms mapping represented as a list of aliases or a dict of entries; only list and dict types are supported.
183
+ alias (str): The room alias to replace (e.g., "#room:server").
184
+ resolved_id (str): The canonical room ID to substitute for the alias (e.g., "!abcdef:server").
185
+
186
+ Returns:
187
+ bool: True if the alias was found and replaced with resolved_id; False if the mapping type is unsupported or the alias was not present.
188
+ """
189
+
190
+ if not isinstance(mapping, (list, dict)):
191
+ return False
192
+
193
+ for existing_alias, setter in _iter_room_alias_entries(mapping):
194
+ if existing_alias == alias:
195
+ setter(resolved_id)
196
+ return True
197
+ return False
198
+
199
+
200
+ def _display_room_channel_mappings(
201
+ rooms: Dict[str, Any], config: Dict[str, Any], e2ee_status: Dict[str, Any]
202
+ ) -> None:
203
+ """
204
+ Log Matrix rooms grouped by Meshtastic channel, showing mapping counts and E2EE/encryption indicators.
205
+
206
+ 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").
207
+
208
+ Parameters:
209
+ 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).
210
+ config (dict): Configuration dict containing a "matrix_rooms" section; entries should include "id" and "meshtastic_channel" when using dict/list room formats.
211
+ e2ee_status (dict): E2EE status information; function expects an "overall_status" key used to determine messaging/encryption indicators.
212
+
213
+ Returns:
214
+ None
215
+ """
216
+ if not rooms:
217
+ logger.info("Bot is not in any Matrix rooms")
218
+ return
219
+
220
+ # Get matrix_rooms configuration
221
+ matrix_rooms_config = config.get("matrix_rooms", [])
222
+ if not matrix_rooms_config:
223
+ logger.info("No matrix_rooms configuration found")
224
+ return
225
+
226
+ # Normalize matrix_rooms configuration to list format
227
+ if isinstance(matrix_rooms_config, dict):
228
+ # Convert dict format to list format
229
+ matrix_rooms_list = list(matrix_rooms_config.values())
230
+ else:
231
+ # Already in list format
232
+ matrix_rooms_list = matrix_rooms_config
233
+
234
+ # Create mapping of room_id -> channel number
235
+ room_to_channel = {}
236
+ for room_config in matrix_rooms_list:
237
+ if isinstance(room_config, dict):
238
+ room_id = room_config.get("id")
239
+ channel = room_config.get("meshtastic_channel")
240
+ if room_id and channel is not None:
241
+ room_to_channel[room_id] = channel
242
+
243
+ # Group rooms by channel
244
+ channels = {}
245
+
246
+ for room_id, room in rooms.items():
247
+ if room_id in room_to_channel:
248
+ channel = room_to_channel[room_id]
249
+ if channel not in channels:
250
+ channels[channel] = []
251
+ channels[channel].append((room_id, room))
252
+
253
+ # Display header
254
+ mapped_rooms = sum(len(room_list) for room_list in channels.values())
255
+ logger.info(f"Matrix Rooms → Meshtastic Channels ({mapped_rooms} configured):")
256
+
257
+ # Display rooms organized by channel (sorted by channel number)
258
+ for channel in sorted(channels.keys()):
259
+ room_list = channels[channel]
260
+ logger.info(f" Channel {channel}:")
261
+
262
+ for room_id, room in room_list:
263
+ room_name = getattr(room, "display_name", room_id)
264
+ encrypted = getattr(room, "encrypted", False)
265
+
266
+ # Format with encryption status
267
+ if e2ee_status["overall_status"] == "ready":
268
+ if encrypted:
269
+ logger.info(f" 🔒 {room_name}")
270
+ else:
271
+ logger.info(f" ✅ {room_name}")
272
+ else:
273
+ if encrypted:
274
+ if e2ee_status["overall_status"] == "unavailable":
275
+ logger.info(
276
+ f" ⚠️ {room_name} (E2EE not supported - messages blocked)"
277
+ )
278
+ elif e2ee_status["overall_status"] == "disabled":
279
+ logger.info(
280
+ f" ⚠️ {room_name} (E2EE disabled - messages blocked)"
281
+ )
282
+ else:
283
+ logger.info(
284
+ f" ⚠️ {room_name} (E2EE incomplete - messages may be blocked)"
285
+ )
286
+ else:
287
+ logger.info(f" ✅ {room_name}")
288
+
289
+
290
+ def _can_auto_create_credentials(matrix_config: dict) -> bool:
291
+ """
292
+ Return True if the Matrix config provides non-empty strings for homeserver, a user id (bot_user_id or user_id), and password.
293
+
294
+ 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.
295
+
296
+ Parameters:
297
+ matrix_config (dict): The `matrix` section from config.yaml.
298
+
299
+ Returns:
300
+ bool: True when homeserver, (bot_user_id or user_id), and password are all present and non-empty strings; otherwise False.
301
+ """
302
+ homeserver = matrix_config.get("homeserver")
303
+ user = matrix_config.get("bot_user_id") or matrix_config.get("user_id")
304
+ password = matrix_config.get("password")
305
+ return all(isinstance(v, str) and v.strip() for v in (homeserver, user, password))
306
+
307
+
308
+ def _normalize_bot_user_id(homeserver: str, bot_user_id: str) -> str:
309
+ """
310
+ Normalize a bot user identifier into a full Matrix MXID.
311
+
312
+ Accepts several common input forms and returns a normalized Matrix ID of the form
313
+ "@localpart:server". Behavior:
314
+ - If bot_user_id is falsy, it is returned unchanged.
315
+ - If bot_user_id already contains a server part (e.g. "@user:server.com" or "user:server.com"),
316
+ the existing server is preserved (any trailing numeric port is removed).
317
+ - If bot_user_id lacks a server part (e.g. "@user" or "user"), the server domain is derived
318
+ from the provided homeserver and appended.
319
+ - The homeserver argument is tolerant of missing URL scheme and will extract the hostname
320
+ portion (handles inputs like "example.com", "https://example.com:8448", or
321
+ "[::1]:8448/path").
322
+
323
+ Parameters:
324
+ homeserver (str): The Matrix homeserver URL or host used to derive a server domain.
325
+ bot_user_id (str): A bot identifier in one of several forms (with or without leading "@"
326
+ and with or without a server part).
327
+
328
+ Returns:
329
+ str: A normalized Matrix user ID in the form "@localpart:server".
330
+ """
331
+ if not bot_user_id:
332
+ return bot_user_id
333
+
334
+ def _canonical_server(value: str | None) -> str | None:
335
+ if not value:
336
+ return value
337
+ value = value.strip()
338
+ if value.startswith("[") and "]" in value:
339
+ closing_index = value.find("]")
340
+ value = value[1:closing_index]
341
+ if value.count(":") == 1 and re.search(r":\d+$", value):
342
+ value = value.rsplit(":", 1)[0]
343
+ if ":" in value and not value.startswith("["):
344
+ value = f"[{value}]"
345
+ return value
346
+
347
+ # Derive domain from homeserver (tolerate missing scheme; drop brackets/port/paths)
348
+ parsed = urlparse(homeserver)
349
+ domain = parsed.hostname or urlparse(f"//{homeserver}").hostname
350
+ if not domain:
351
+ # Last-ditch fallback for malformed inputs; drop any trailing :port
352
+ host = homeserver.split("://")[-1].split("/", 1)[0]
353
+ domain = re.sub(r":\d+$", "", host)
354
+
355
+ domain = _canonical_server(domain)
356
+
357
+ # Normalize user ID
358
+ localpart, *serverpart = bot_user_id.lstrip("@").split(":", 1)
359
+ if serverpart and serverpart[0]:
360
+ # Already has a server part; drop any brackets/port consistently
361
+ raw_server = serverpart[0]
362
+ server = urlparse(f"//{raw_server}").hostname or re.sub(
363
+ r":\d+$",
364
+ "",
365
+ raw_server,
366
+ )
367
+ server = _canonical_server(server)
368
+ return f"@{localpart}:{server}"
369
+
370
+ # No server part, add the derived domain
371
+ return f"@{localpart.rstrip(':')}:{domain}"
372
+
373
+
374
+ def _get_msgs_to_keep_config():
375
+ """
376
+ Return the configured number of Meshtastic–Matrix message mappings to retain.
377
+
378
+ Reads the global `config` and prefers the new location `database.msg_map.msgs_to_keep`.
379
+ If that section is absent, falls back to the legacy `db.msg_map.msgs_to_keep` and emits a deprecation warning.
380
+ If no configuration is available or `msgs_to_keep` is not set, returns DEFAULT_MSGS_TO_KEEP.
381
+
382
+ Returns:
383
+ int: Number of message mappings to keep.
384
+ """
385
+ global config
386
+ if not config:
387
+ return DEFAULT_MSGS_TO_KEEP
388
+
389
+ msg_map_config = config.get("database", {}).get("msg_map", {})
390
+
391
+ # If not found in database config, check legacy db config
392
+ if not msg_map_config:
393
+ legacy_msg_map_config = config.get("db", {}).get("msg_map", {})
394
+
395
+ if legacy_msg_map_config:
396
+ msg_map_config = legacy_msg_map_config
397
+ logger.warning(
398
+ "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."
399
+ )
400
+
401
+ return msg_map_config.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
402
+
403
+
404
+ def _get_detailed_sync_error_message(sync_response) -> str:
405
+ """
406
+ Return a concise, user-facing explanation for why an initial Matrix sync failed.
407
+
408
+ Given a sync response or error object (commonly a nio ErrorResponse, an HTTP/transport error, raw bytes, or any object exposing `message`, `status_code`, or `transport_response`), extract the most specific human-readable reason available and map common HTTP/transport conditions to short, actionable messages (e.g., authentication failure, forbidden, not found, rate limited, server error). Falls back to a generic network/connectivity message when no specific detail can be reliably extracted.
409
+
410
+ Parameters:
411
+ sync_response: The sync response or error object to summarize. May be bytes/bytearray, a nio ErrorResponse-like object, or any object with `message`, `status_code`, or `transport_response` attributes.
412
+
413
+ Returns:
414
+ str: A short, user-focused error description suitable for logs and brief troubleshooting hints.
415
+ """
416
+ try:
417
+ # Handle bytes/bytearray types by converting to string
418
+ if isinstance(sync_response, (bytes, bytearray)):
419
+ try:
420
+ sync_response = sync_response.decode("utf-8")
421
+ except UnicodeDecodeError:
422
+ return "Network connectivity issue or server unreachable (binary data)"
423
+
424
+ # Try to extract specific error information
425
+ if hasattr(sync_response, "message") and sync_response.message:
426
+ message = sync_response.message
427
+ # Handle if message is bytes/bytearray
428
+ if isinstance(message, (bytes, bytearray)):
429
+ try:
430
+ message = message.decode("utf-8")
431
+ except UnicodeDecodeError:
432
+ return "Network connectivity issue or server unreachable"
433
+ return message
434
+ elif hasattr(sync_response, "status_code") and sync_response.status_code:
435
+ status_code = sync_response.status_code
436
+ # Handle if status_code is not an int
437
+ try:
438
+ status_code = int(status_code)
439
+ except (ValueError, TypeError):
440
+ return "Network connectivity issue or server unreachable"
441
+
442
+ if status_code == 401:
443
+ return "Authentication failed - invalid or expired credentials"
444
+ elif status_code == 403:
445
+ return "Access forbidden - check user permissions"
446
+ elif status_code == 404:
447
+ return "Server not found - check homeserver URL"
448
+ elif status_code == 429:
449
+ return "Rate limited - too many requests"
450
+ elif status_code >= 500:
451
+ return f"Server error (HTTP {status_code}) - the Matrix server is experiencing issues"
452
+ else:
453
+ return f"HTTP error {status_code}"
454
+ elif hasattr(sync_response, "transport_response"):
455
+ # Check for transport-level errors
456
+ transport = sync_response.transport_response
457
+ if hasattr(transport, "status_code"):
458
+ try:
459
+ status_code = int(transport.status_code)
460
+ return f"Transport error: HTTP {status_code}"
461
+ except (ValueError, TypeError):
462
+ return "Network connectivity issue or server unreachable"
463
+
464
+ # Fallback to string representation with safety checks
465
+ try:
466
+ error_str = str(sync_response)
467
+ except Exception:
468
+ return "Network connectivity issue or server unreachable"
469
+
470
+ # Clean up object repr strings that contain angle brackets
471
+ if error_str and error_str != "None":
472
+ # Remove object repr patterns like <object at 0x...>
473
+ if "<" in error_str and ">" in error_str and " at 0x" in error_str:
474
+ return "Network connectivity issue or server unreachable"
475
+ # Remove HTML/XML-like content
476
+ elif "<" in error_str and ">" in error_str:
477
+ return "Network connectivity issue or server unreachable"
478
+ elif "unknown error" in error_str.lower():
479
+ return "Network connectivity issue or server unreachable"
480
+ else:
481
+ return error_str
482
+ else:
483
+ return "Network connectivity issue or server unreachable"
484
+
485
+ except (AttributeError, ValueError, TypeError) as e:
486
+ logger.debug(
487
+ "Failed to extract sync error details from %r: %s", sync_response, e
488
+ )
489
+ # If we can't extract error details, provide a generic but helpful message
490
+ return (
491
+ "Unable to determine specific error - likely a network connectivity issue"
492
+ )
493
+
494
+
495
+ def _create_mapping_info(
496
+ matrix_event_id, room_id, text, meshnet=None, msgs_to_keep=None
497
+ ):
498
+ """
499
+ Create metadata linking a Matrix event to a Meshtastic message for cross-network mapping.
500
+
501
+ Strips quoted lines from `text` and populates a mapping dict containing identifiers and retention settings. If `msgs_to_keep` is None the value is obtained from _get_msgs_to_keep_config(). Returns None when any of `matrix_event_id`, `room_id`, or `text` is missing or falsy.
502
+
503
+ Returns:
504
+ dict or None: Mapping with keys:
505
+ - matrix_event_id: original Matrix event id
506
+ - room_id: Matrix room id
507
+ - text: cleaned text with quoted lines removed
508
+ - meshnet: optional meshnet name (may be None)
509
+ - msgs_to_keep: number of message mappings to retain
510
+ """
511
+ if not matrix_event_id or not room_id or not text:
512
+ return None
513
+
514
+ if msgs_to_keep is None:
515
+ msgs_to_keep = _get_msgs_to_keep_config()
516
+
517
+ return {
518
+ "matrix_event_id": matrix_event_id,
519
+ "room_id": room_id,
520
+ "text": strip_quoted_lines(text),
521
+ "meshnet": meshnet,
522
+ "msgs_to_keep": msgs_to_keep,
523
+ }
524
+
525
+
526
+ def get_interaction_settings(config):
527
+ """
528
+ Determine if message reactions and replies are enabled in the configuration.
529
+
530
+ 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.
531
+ """
532
+ if config is None:
533
+ return {"reactions": False, "replies": False}
534
+
535
+ meshtastic_config = config.get("meshtastic", {})
536
+
537
+ # Check for new structured configuration first
538
+ if "message_interactions" in meshtastic_config:
539
+ interactions = meshtastic_config["message_interactions"]
540
+ return {
541
+ "reactions": interactions.get("reactions", False),
542
+ "replies": interactions.get("replies", False),
543
+ }
544
+
545
+ # Fall back to legacy relay_reactions setting
546
+ if "relay_reactions" in meshtastic_config:
547
+ enabled = meshtastic_config["relay_reactions"]
548
+ logger.warning(
549
+ "Configuration setting 'relay_reactions' is deprecated. "
550
+ "Please use 'message_interactions: {reactions: bool, replies: bool}' instead. "
551
+ "Legacy mode: enabling reactions only."
552
+ )
553
+ return {
554
+ "reactions": enabled,
555
+ "replies": False,
556
+ } # Only reactions for legacy compatibility
557
+
558
+ # Default to privacy-first (both disabled)
559
+ return {"reactions": False, "replies": False}
560
+
561
+
562
+ def message_storage_enabled(interactions):
563
+ """
564
+ Determine if message storage is needed based on enabled message interactions.
565
+
566
+ Returns:
567
+ True if either reactions or replies are enabled in the interactions dictionary; otherwise, False.
568
+ """
569
+ return interactions["reactions"] or interactions["replies"]
570
+
571
+
572
+ def _add_truncated_vars(format_vars, prefix, text):
573
+ """Helper function to add variable length truncation variables to format_vars dict."""
574
+ # Always add truncated variables, even for empty text (to prevent KeyError)
575
+ text = text or "" # Convert None to empty string
576
+ logger.debug(f"Adding truncated vars for prefix='{prefix}', text='{text}'")
577
+ for i in range(
578
+ 1, MAX_TRUNCATION_LENGTH + 1
579
+ ): # Support up to MAX_TRUNCATION_LENGTH chars, always add all variants
580
+ truncated_value = text[:i]
581
+ format_vars[f"{prefix}{i}"] = truncated_value
582
+ if i <= TRUNCATION_LOG_LIMIT: # Only log first few to avoid spam
583
+ logger.debug(f" {prefix}{i} = '{truncated_value}'")
584
+
585
+
586
+ def validate_prefix_format(format_string, available_vars):
587
+ """Validate prefix format string against available variables.
588
+
589
+ Args:
590
+ format_string (str): The format string to validate.
591
+ available_vars (dict): Dictionary of available variables with test values.
592
+
593
+ Returns:
594
+ tuple: (is_valid: bool, error_message: str or None)
595
+ """
596
+ try:
597
+ # Test format with dummy data
598
+ format_string.format(**available_vars)
599
+ return True, None
600
+ except (KeyError, ValueError) as e:
601
+ return False, str(e)
602
+
603
+
604
+ def get_meshtastic_prefix(config, display_name, user_id=None):
605
+ """
606
+ Generate a Meshtastic message prefix using the configured format, supporting variable-length truncation and user-specific variables.
607
+
608
+ 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.
609
+
610
+ Args:
611
+ config (dict): The application configuration dictionary.
612
+ display_name (str): The user's display name (room-specific or global).
613
+ user_id (str, optional): The user's Matrix ID (@user:server.com).
614
+
615
+ Returns:
616
+ str: The formatted prefix string if enabled, empty string otherwise.
617
+
618
+ Examples:
619
+ Basic usage:
620
+ get_meshtastic_prefix(config, "Alice Smith")
621
+ # Returns: "Alice[M]: " (with default format)
622
+
623
+ Custom format:
624
+ config = {"meshtastic": {"prefix_format": "{display8}> "}}
625
+ get_meshtastic_prefix(config, "Alice Smith")
626
+ # Returns: "Alice Sm> "
627
+ """
628
+ meshtastic_config = config.get("meshtastic", {})
629
+
630
+ # Check if prefixes are enabled
631
+ if not meshtastic_config.get("prefix_enabled", True):
632
+ return ""
633
+
634
+ # Get custom format or use default
635
+ prefix_format = meshtastic_config.get("prefix_format", DEFAULT_MESHTASTIC_PREFIX)
636
+
637
+ # Parse username and server from user_id if available
638
+ username = ""
639
+ server = ""
640
+ if user_id:
641
+ # Extract username and server from @username:server.com format
642
+ if user_id.startswith("@") and ":" in user_id:
643
+ parts = user_id[1:].split(":", 1) # Remove @ and split on first :
644
+ username = parts[0]
645
+ server = parts[1] if len(parts) > 1 else ""
646
+
647
+ # Available variables for formatting with variable length support
648
+ format_vars = {
649
+ "display": display_name or "",
650
+ "user": user_id or "",
651
+ "username": username,
652
+ "server": server,
653
+ }
654
+
655
+ # Add variable length display name truncation (display1, display2, display3, etc.)
656
+ _add_truncated_vars(format_vars, "display", display_name)
657
+
658
+ try:
659
+ return prefix_format.format(**format_vars)
660
+ except (KeyError, ValueError) as e:
661
+ # Fallback to default format if custom format is invalid
662
+ logger.warning(
663
+ f"Invalid prefix_format '{prefix_format}': {e}. Using default format."
664
+ )
665
+ # The default format only uses 'display5', which is safe to format
666
+ return DEFAULT_MESHTASTIC_PREFIX.format(
667
+ display5=display_name[:DISPLAY_NAME_DEFAULT_LENGTH] if display_name else ""
668
+ )
669
+
670
+
671
+ def get_matrix_prefix(config, longname, shortname, meshnet_name):
672
+ """
673
+ Generates a formatted prefix string for Meshtastic messages relayed to Matrix, based on configuration settings and sender/mesh network names.
674
+
675
+ 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.
676
+
677
+ Parameters:
678
+ longname (str): Full Meshtastic sender name.
679
+ shortname (str): Short Meshtastic sender name.
680
+ meshnet_name (str): Name of the mesh network.
681
+
682
+ Returns:
683
+ str: The formatted prefix string, or an empty string if prefixing is disabled.
684
+ """
685
+ matrix_config = config.get(CONFIG_SECTION_MATRIX, {})
686
+
687
+ # Enhanced debug logging for configuration troubleshooting
688
+ logger.debug(
689
+ f"get_matrix_prefix called with longname='{longname}', shortname='{shortname}', meshnet_name='{meshnet_name}'"
690
+ )
691
+ logger.debug(f"Matrix config section: {matrix_config}")
692
+
693
+ # Check if prefixes are enabled for Matrix direction
694
+ if not matrix_config.get("prefix_enabled", True):
695
+ logger.debug("Matrix prefixes are disabled, returning empty string")
696
+ return ""
697
+
698
+ # Get custom format or use default
699
+ matrix_prefix_format = matrix_config.get("prefix_format", DEFAULT_MATRIX_PREFIX)
700
+ logger.debug(
701
+ f"Using matrix prefix format: '{matrix_prefix_format}' (default: '{DEFAULT_MATRIX_PREFIX}')"
702
+ )
703
+
704
+ # Available variables for formatting with variable length support
705
+ format_vars = {
706
+ "long": longname,
707
+ "short": shortname,
708
+ "mesh": meshnet_name,
709
+ }
710
+
711
+ # Add variable length truncation for longname and mesh name
712
+ _add_truncated_vars(format_vars, "long", longname)
713
+ _add_truncated_vars(format_vars, "mesh", meshnet_name)
714
+
715
+ try:
716
+ result = matrix_prefix_format.format(**format_vars)
717
+ logger.debug(
718
+ f"Matrix prefix generated: '{result}' using format '{matrix_prefix_format}' with vars {format_vars}"
719
+ )
720
+ # Additional debug to help identify the issue
721
+ if result == f"[{longname}/{meshnet_name}]: ":
722
+ logger.debug(
723
+ "Generated prefix matches default format - check if custom configuration is being loaded correctly"
724
+ )
725
+ return result
726
+ except (KeyError, ValueError) as e:
727
+ # Fallback to default format if custom format is invalid
728
+ logger.warning(
729
+ f"Invalid matrix prefix_format '{matrix_prefix_format}': {e}. Using default format."
730
+ )
731
+ # The default format only uses 'long' and 'mesh', which are safe
732
+ return DEFAULT_MATRIX_PREFIX.format(
733
+ long=longname or "", mesh=meshnet_name or ""
734
+ )
735
+
736
+
737
+ # Global config variable that will be set from config.py
738
+ config = None
739
+
740
+ # These will be set in connect_matrix()
741
+ matrix_homeserver = None
742
+ matrix_rooms = None
743
+ matrix_access_token = None
744
+ bot_user_id = None
745
+ bot_user_name = None # Detected upon logon
746
+ bot_start_time = int(
747
+ time.time() * MILLISECONDS_PER_SECOND
748
+ ) # Timestamp when the bot starts, used to filter out old messages
749
+
750
+
751
+ matrix_client = None
752
+
753
+
754
+ def bot_command(command, event):
755
+ """
756
+ Checks if the given command is directed at the bot,
757
+ accounting for variations in different Matrix clients.
758
+ """
759
+ full_message = event.body.strip()
760
+ content = event.source.get("content", {})
761
+ formatted_body = content.get("formatted_body", "")
762
+
763
+ # Remove HTML tags and extract the text content
764
+ text_content = re.sub(r"<[^>]+>", "", formatted_body).strip()
765
+
766
+ # Check for simple !command format first
767
+ if full_message.startswith(f"!{command}") or text_content.startswith(f"!{command}"):
768
+ return True
769
+
770
+ # Check if the message starts with bot_user_id or bot_user_name
771
+ if full_message.startswith(bot_user_id) or text_content.startswith(bot_user_id):
772
+ # Construct a regex pattern to match variations of bot mention and command
773
+ pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
774
+ return bool(re.match(pattern, full_message)) or bool(
775
+ re.match(pattern, text_content)
776
+ )
777
+ elif full_message.startswith(bot_user_name) or text_content.startswith(
778
+ bot_user_name
779
+ ):
780
+ # Construct a regex pattern to match variations of bot mention and command
781
+ pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
782
+ return bool(re.match(pattern, full_message)) or bool(
783
+ re.match(pattern, text_content)
784
+ )
785
+ else:
786
+ return False
787
+
788
+
789
+ async def connect_matrix(passed_config=None):
790
+ """
791
+ Initialize and return a configured matrix-nio AsyncClient connected to the configured Matrix homeserver.
792
+
793
+ Creates or restores client credentials (prefers credentials.json, falls back to automatic login using username/password from config, then to direct tokens in config), optionally enables End-to-End Encryption when configured and dependencies are available, performs an initial full-state sync to populate rooms, resolves room aliases found in configuration, and sets module-level connection state used by other functions.
794
+
795
+ Parameters:
796
+ passed_config (dict | None): Optional configuration to use for this connection attempt; when provided it overrides the module-level config for this call.
797
+
798
+ Returns:
799
+ AsyncClient | None: A ready-to-use matrix-nio AsyncClient on success, or `None` if connection or credentials are unavailable.
800
+
801
+ Raises:
802
+ ValueError: If the required top-level "matrix_rooms" configuration is missing.
803
+ ConnectionError: If the initial Matrix sync fails or times out.
804
+ """
805
+ global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
806
+
807
+ # Update the global config if a config is passed
808
+ if passed_config is not None:
809
+ config = passed_config
810
+
811
+ # Check if config is available
812
+ if config is None:
813
+ logger.error("No configuration available. Cannot connect to Matrix.")
814
+ return None
815
+
816
+ # Check if client already exists
817
+ if matrix_client:
818
+ return matrix_client
819
+
820
+ # Check for credentials.json first
821
+ credentials = None
822
+ credentials_path = None
823
+
824
+ # Try to find credentials.json in the config directory
825
+ try:
826
+ from mmrelay.config import get_base_dir
827
+
828
+ config_dir = get_base_dir()
829
+ credentials_path = os.path.join(config_dir, "credentials.json")
830
+
831
+ if os.path.exists(credentials_path):
832
+ with open(credentials_path, "r", encoding="utf-8") as f:
833
+ credentials = json.load(f)
834
+ except Exception as e:
835
+ logger.warning(f"Error loading credentials: {e}")
836
+
837
+ # If credentials.json exists, use it
838
+ if credentials:
839
+ matrix_homeserver = credentials["homeserver"]
840
+ matrix_access_token = credentials["access_token"]
841
+ bot_user_id = credentials["user_id"]
842
+ e2ee_device_id = credentials.get("device_id")
843
+
844
+ # Log consolidated credentials info
845
+ logger.debug(f"Using Matrix credentials (device: {e2ee_device_id})")
846
+
847
+ # If device_id is missing, warn but proceed; we'll learn and persist it after restore_login().
848
+ if not isinstance(e2ee_device_id, str) or not e2ee_device_id.strip():
849
+ logger.warning(
850
+ "credentials.json has no valid device_id; proceeding to restore session and discover device_id."
851
+ )
852
+ e2ee_device_id = None
853
+
854
+ # If config also has Matrix login info, let the user know we're ignoring it
855
+ if config and "matrix" in config and "access_token" in config["matrix"]:
856
+ logger.info(
857
+ "NOTE: Ignoring Matrix login details in config.yaml in favor of credentials.json"
858
+ )
859
+ # Check if we can automatically create credentials from config.yaml
860
+ elif (
861
+ config and "matrix" in config and _can_auto_create_credentials(config["matrix"])
862
+ ):
863
+ logger.info(
864
+ "No credentials.json found, but config.yaml has password field. Attempting automatic login..."
865
+ )
866
+
867
+ matrix_section = config["matrix"]
868
+ homeserver = matrix_section["homeserver"]
869
+ username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
870
+ # Normalize the username to ensure it's a full MXID
871
+ if username:
872
+ username = _normalize_bot_user_id(homeserver, username)
873
+ password = matrix_section["password"]
874
+
875
+ # Attempt automatic login
876
+ try:
877
+ success = await login_matrix_bot(
878
+ homeserver=homeserver,
879
+ username=username,
880
+ password=password,
881
+ logout_others=False,
882
+ )
883
+
884
+ if success:
885
+ logger.info(
886
+ "Automatic login successful! Credentials saved to credentials.json"
887
+ )
888
+ # Load the newly created credentials and set up for credentials flow
889
+ credentials = load_credentials()
890
+ if not credentials:
891
+ logger.error("Failed to load newly created credentials")
892
+ return None
893
+
894
+ # Set up variables for credentials-based connection
895
+ matrix_homeserver = credentials["homeserver"]
896
+ matrix_access_token = credentials["access_token"]
897
+ bot_user_id = credentials["user_id"]
898
+ e2ee_device_id = credentials.get("device_id")
899
+ else:
900
+ logger.error(
901
+ "Automatic login failed. Please check your credentials or use 'mmrelay auth login'"
902
+ )
903
+ return None
904
+ except Exception as e:
905
+ logger.exception(f"Error during automatic login: {type(e).__name__}")
906
+ logger.error("Please use 'mmrelay auth login' for interactive setup")
907
+ return None
908
+ else:
909
+ # Check if config is available
910
+ if config is None:
911
+ logger.error("No configuration available. Cannot connect to Matrix.")
912
+ return None
913
+
914
+ # Check if matrix section exists in config
915
+ if "matrix" not in config:
916
+ logger.error(
917
+ "No Matrix authentication available. Neither credentials.json nor matrix section in config found."
918
+ )
919
+ logger.error(msg_require_auth_login())
920
+ return None
921
+
922
+ matrix_section = config["matrix"]
923
+
924
+ # Check for required fields in matrix section
925
+ required_fields = ["homeserver", "access_token", "bot_user_id"]
926
+ missing_fields = [
927
+ field for field in required_fields if field not in matrix_section
928
+ ]
929
+
930
+ if missing_fields:
931
+ logger.error(f"Matrix section is missing required fields: {missing_fields}")
932
+ logger.error(msg_require_auth_login())
933
+ return None
934
+
935
+ # Extract Matrix configuration from config
936
+ matrix_homeserver = matrix_section["homeserver"]
937
+ matrix_access_token = matrix_section["access_token"]
938
+ bot_user_id = _normalize_bot_user_id(
939
+ matrix_homeserver, matrix_section["bot_user_id"]
940
+ )
941
+
942
+ # Manual method does not support device_id - use auth system for E2EE
943
+ e2ee_device_id = None
944
+
945
+ # Get matrix rooms from config
946
+ if "matrix_rooms" not in config:
947
+ logger.error("Configuration is missing 'matrix_rooms' section")
948
+ logger.error(
949
+ "Please ensure your config.yaml includes matrix_rooms configuration"
950
+ )
951
+ raise ValueError("Missing required 'matrix_rooms' configuration")
952
+ matrix_rooms = config["matrix_rooms"]
953
+
954
+ # Create SSL context using certifi's certificates with system default fallback
955
+ ssl_context = _create_ssl_context()
956
+ if ssl_context is None:
957
+ logger.warning(
958
+ "Failed to create certifi/system SSL context; proceeding with AsyncClient defaults"
959
+ )
960
+
961
+ # Check if E2EE is enabled
962
+ e2ee_enabled = False
963
+ e2ee_store_path = None
964
+ # Only initialize e2ee_device_id if not already set from credentials
965
+ if "e2ee_device_id" not in locals():
966
+ e2ee_device_id = None
967
+
968
+ try:
969
+ from mmrelay.config import is_e2ee_enabled
970
+
971
+ # Check if E2EE is enabled using the helper function
972
+ e2ee_enabled = is_e2ee_enabled(config)
973
+
974
+ # Debug logging for E2EE detection
975
+ logger.debug(
976
+ f"E2EE detection: matrix config section present: {'matrix' in config}"
977
+ )
978
+ logger.debug(f"E2EE detection: e2ee enabled = {e2ee_enabled}")
979
+
980
+ if e2ee_enabled:
981
+ # Check if running on Windows
982
+ if sys.platform == WINDOWS_PLATFORM:
983
+ logger.error(
984
+ "E2EE is not supported on Windows due to library limitations."
985
+ )
986
+ logger.error(
987
+ "The python-olm library requires native C libraries that are difficult to install on Windows."
988
+ )
989
+ logger.error(
990
+ "Please disable E2EE in your configuration or use a Linux/macOS system for E2EE support."
991
+ )
992
+ e2ee_enabled = False
993
+ else:
994
+ # Check if python-olm is installed
995
+ try:
996
+ importlib.import_module("olm")
997
+
998
+ # Also check for other required E2EE dependencies unless tests skip them
999
+ if os.getenv("MMRELAY_TESTING") != "1":
1000
+ try:
1001
+ nio_crypto = importlib.import_module("nio.crypto")
1002
+ if not hasattr(nio_crypto, "OlmDevice"):
1003
+ raise ImportError("nio.crypto.OlmDevice is unavailable")
1004
+
1005
+ nio_store = importlib.import_module("nio.store")
1006
+ if not hasattr(nio_store, "SqliteStore"):
1007
+ raise ImportError(
1008
+ "nio.store.SqliteStore is unavailable"
1009
+ )
1010
+
1011
+ logger.debug("All E2EE dependencies are available")
1012
+ except ImportError:
1013
+ logger.exception("Missing E2EE dependency")
1014
+ logger.error(
1015
+ "Please reinstall with: pipx install 'mmrelay[e2e]'"
1016
+ )
1017
+ logger.warning("E2EE will be disabled for this session.")
1018
+ e2ee_enabled = False
1019
+ else:
1020
+ # Dependencies are available, keep the config-determined value
1021
+ if e2ee_enabled:
1022
+ logger.info("End-to-End Encryption (E2EE) is enabled")
1023
+ else:
1024
+ logger.debug(
1025
+ "E2EE dependencies available but E2EE is disabled in configuration"
1026
+ )
1027
+ else:
1028
+ logger.debug(
1029
+ "Skipping additional E2EE dependency imports in test mode"
1030
+ )
1031
+
1032
+ if e2ee_enabled:
1033
+ # Ensure nio still receives a store path even when dependency
1034
+ # checks are skipped (e.g. production runs without MMRELAY_TESTING);
1035
+ # without this the client will not load encryption state.
1036
+ # Get store path from config or use default
1037
+ if (
1038
+ "encryption" in config["matrix"]
1039
+ and "store_path" in config["matrix"]["encryption"]
1040
+ ):
1041
+ e2ee_store_path = os.path.expanduser(
1042
+ config["matrix"]["encryption"]["store_path"]
1043
+ )
1044
+ elif (
1045
+ "e2ee" in config["matrix"]
1046
+ and "store_path" in config["matrix"]["e2ee"]
1047
+ ):
1048
+ e2ee_store_path = os.path.expanduser(
1049
+ config["matrix"]["e2ee"]["store_path"]
1050
+ )
1051
+ else:
1052
+ e2ee_store_path = get_e2ee_store_dir()
1053
+
1054
+ # Create store directory if it doesn't exist
1055
+ os.makedirs(e2ee_store_path, exist_ok=True)
1056
+
1057
+ # Check if store directory contains database files
1058
+ store_files = (
1059
+ os.listdir(e2ee_store_path)
1060
+ if os.path.exists(e2ee_store_path)
1061
+ else []
1062
+ )
1063
+ db_files = [f for f in store_files if f.endswith(".db")]
1064
+ if db_files:
1065
+ logger.debug(
1066
+ f"Found existing E2EE store files: {', '.join(db_files)}"
1067
+ )
1068
+ else:
1069
+ logger.warning(
1070
+ "No existing E2EE store files found. Encryption may not work correctly."
1071
+ )
1072
+
1073
+ logger.debug(f"Using E2EE store path: {e2ee_store_path}")
1074
+
1075
+ # If device_id is not present in credentials, we can attempt to learn it later.
1076
+ if not e2ee_device_id:
1077
+ logger.debug(
1078
+ "No device_id in credentials; will retrieve from store/whoami later if available"
1079
+ )
1080
+ except ImportError:
1081
+ logger.warning(
1082
+ "E2EE is enabled in config but python-olm is not installed."
1083
+ )
1084
+ logger.warning("Install 'mmrelay[e2e]' to use E2EE features.")
1085
+ e2ee_enabled = False
1086
+ except (KeyError, TypeError):
1087
+ # E2EE not configured
1088
+ pass
1089
+
1090
+ # Initialize the Matrix client with custom SSL context
1091
+ # Use the same AsyncClientConfig pattern as working E2EE examples
1092
+ client_config = AsyncClientConfig(
1093
+ max_limit_exceeded=0,
1094
+ max_timeouts=0,
1095
+ store_sync_tokens=True,
1096
+ encryption_enabled=e2ee_enabled,
1097
+ )
1098
+
1099
+ # Log the device ID being used
1100
+ if e2ee_device_id:
1101
+ logger.debug(f"Device ID from credentials: {e2ee_device_id}")
1102
+
1103
+ matrix_client = AsyncClient(
1104
+ homeserver=matrix_homeserver,
1105
+ user=bot_user_id,
1106
+ device_id=e2ee_device_id, # Will be None if not specified in config or credentials
1107
+ store_path=e2ee_store_path if e2ee_enabled else None,
1108
+ config=client_config,
1109
+ ssl=ssl_context,
1110
+ )
1111
+
1112
+ # Set the access_token and user_id using restore_login for better session management
1113
+ if credentials:
1114
+ # Use restore_login method for proper session restoration.
1115
+ # nio will handle loading the store automatically if store_path was provided
1116
+ # to the client constructor.
1117
+ matrix_client.restore_login(
1118
+ user_id=bot_user_id,
1119
+ device_id=e2ee_device_id,
1120
+ access_token=matrix_access_token,
1121
+ )
1122
+ logger.info(
1123
+ f"Restored login session for {bot_user_id} with device {e2ee_device_id}"
1124
+ )
1125
+
1126
+ # If the device_id was not known up-front, capture what nio has after restore.
1127
+ if not e2ee_device_id and getattr(matrix_client, "device_id", None):
1128
+ e2ee_device_id = matrix_client.device_id
1129
+ logger.debug(f"Device ID established after restore_login: {e2ee_device_id}")
1130
+ try:
1131
+ if credentials is not None:
1132
+ credentials["device_id"] = e2ee_device_id
1133
+ save_credentials(credentials)
1134
+ logger.info("Updated credentials.json with discovered device_id")
1135
+ except Exception as e:
1136
+ logger.debug(f"Failed to persist discovered device_id: {e}")
1137
+ else:
1138
+ # Fallback to direct assignment for legacy token-based auth
1139
+ matrix_client.access_token = matrix_access_token
1140
+ matrix_client.user_id = bot_user_id
1141
+
1142
+ # If E2EE is enabled, upload keys if necessary.
1143
+ # nio will have loaded the store automatically if store_path was provided.
1144
+ if e2ee_enabled:
1145
+ try:
1146
+ if matrix_client.should_upload_keys:
1147
+ logger.info("Uploading encryption keys...")
1148
+ await matrix_client.keys_upload()
1149
+ logger.info("Encryption keys uploaded successfully")
1150
+ else:
1151
+ logger.debug("No key upload needed - keys already present")
1152
+ except Exception:
1153
+ logger.exception("Failed to upload E2EE keys")
1154
+ # E2EE might still work, so we don't disable it here
1155
+ logger.error("Consider regenerating credentials with: mmrelay auth login")
1156
+
1157
+ # Perform initial sync to populate rooms (needed for message delivery)
1158
+ logger.debug("Performing initial sync to initialize rooms...")
1159
+ try:
1160
+ # A full_state=True sync is required to get room encryption state
1161
+ sync_response = await asyncio.wait_for(
1162
+ matrix_client.sync(timeout=MATRIX_EARLY_SYNC_TIMEOUT, full_state=True),
1163
+ timeout=MATRIX_SYNC_OPERATION_TIMEOUT,
1164
+ )
1165
+ # Check if sync failed by looking for error class name
1166
+ if (
1167
+ hasattr(sync_response, "__class__")
1168
+ and "Error" in sync_response.__class__.__name__
1169
+ ):
1170
+ # Provide more detailed error information
1171
+ error_type = sync_response.__class__.__name__
1172
+ error_details = _get_detailed_sync_error_message(sync_response)
1173
+ logger.error(f"Initial sync failed: {error_type}")
1174
+ logger.error(f"Error details: {error_details}")
1175
+
1176
+ # Provide user-friendly troubleshooting guidance
1177
+ if "SyncError" in error_type:
1178
+ logger.error(
1179
+ "This usually indicates a network connectivity issue or server problem."
1180
+ )
1181
+ logger.error("Troubleshooting steps:")
1182
+ logger.error("1. Check your internet connection")
1183
+ logger.error(
1184
+ f"2. Verify the homeserver URL is correct: {matrix_homeserver}"
1185
+ )
1186
+ logger.error("3. Ensure the Matrix server is online and accessible")
1187
+ logger.error("4. Check if your credentials are still valid")
1188
+
1189
+ try:
1190
+ await matrix_client.close()
1191
+ except Exception:
1192
+ logger.debug("Ignoring error while closing client after sync failure")
1193
+ finally:
1194
+ matrix_client = None
1195
+ raise ConnectionError(f"Matrix sync failed: {error_type} - {error_details}")
1196
+ else:
1197
+ logger.info(
1198
+ f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
1199
+ )
1200
+
1201
+ # List all rooms with unified E2EE status display
1202
+ from mmrelay.config import config_path
1203
+ from mmrelay.e2ee_utils import (
1204
+ get_e2ee_status,
1205
+ get_room_encryption_warnings,
1206
+ )
1207
+
1208
+ # Get comprehensive E2EE status
1209
+ e2ee_status = get_e2ee_status(config, config_path)
1210
+
1211
+ # Resolve room aliases in config (supports list[str|dict] and dict[str->str|dict])
1212
+ async def _resolve_alias(alias: str) -> Optional[str]:
1213
+ """
1214
+ Resolve a Matrix room alias to its canonical room ID.
1215
+
1216
+ Attempts to resolve the provided room alias using the module's Matrix client. Returns the resolved room ID string on success; returns None if the alias cannot be resolved or if an error/timeout occurs (errors from the underlying nio client are caught and handled internally).
1217
+ """
1218
+ logger.debug(f"Resolving alias from config: {alias}")
1219
+ try:
1220
+ response = await matrix_client.room_resolve_alias(alias)
1221
+ if hasattr(response, "room_id") and response.room_id:
1222
+ logger.debug(f"Resolved alias {alias} to {response.room_id}")
1223
+ return response.room_id
1224
+ logger.warning(
1225
+ f"Could not resolve alias {alias}: {getattr(response, 'message', response)}"
1226
+ )
1227
+ except (
1228
+ NioErrorResponse,
1229
+ NioLocalProtocolError,
1230
+ NioRemoteProtocolError,
1231
+ NioLocalTransportError,
1232
+ NioRemoteTransportError,
1233
+ asyncio.TimeoutError,
1234
+ ):
1235
+ logger.exception(f"Error resolving alias {alias}")
1236
+ return None
1237
+
1238
+ await _resolve_aliases_in_mapping(matrix_rooms, _resolve_alias)
1239
+
1240
+ # Display rooms with channel mappings
1241
+ _display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
1242
+
1243
+ # Show warnings for encrypted rooms when E2EE is not ready
1244
+ warnings = get_room_encryption_warnings(matrix_client.rooms, e2ee_status)
1245
+ for warning in warnings:
1246
+ logger.warning(warning)
1247
+
1248
+ # Debug information
1249
+ encrypted_count = sum(
1250
+ 1
1251
+ for room in matrix_client.rooms.values()
1252
+ if getattr(room, "encrypted", False)
1253
+ )
1254
+ logger.debug(
1255
+ f"Found {encrypted_count} encrypted rooms out of {len(matrix_client.rooms)} total rooms"
1256
+ )
1257
+ logger.debug(f"E2EE status: {e2ee_status['overall_status']}")
1258
+
1259
+ # Additional debugging for E2EE enabled case
1260
+ if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
1261
+ logger.debug("No encrypted rooms detected - all rooms are plaintext")
1262
+ except asyncio.TimeoutError:
1263
+ logger.exception(
1264
+ f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
1265
+ )
1266
+ logger.error(
1267
+ "This indicates a network connectivity issue or slow Matrix server."
1268
+ )
1269
+ logger.error("Troubleshooting steps:")
1270
+ logger.error("1. Check your internet connection")
1271
+ logger.error(f"2. Verify the homeserver is accessible: {matrix_homeserver}")
1272
+ logger.error(
1273
+ "3. Try again in a few minutes - the server may be temporarily overloaded"
1274
+ )
1275
+ logger.error(
1276
+ "4. Consider using a different Matrix homeserver if the problem persists"
1277
+ )
1278
+ try:
1279
+ await matrix_client.close()
1280
+ except Exception:
1281
+ logger.debug("Ignoring error while closing client after sync timeout")
1282
+ finally:
1283
+ matrix_client = None
1284
+ raise ConnectionError(
1285
+ f"Matrix sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds - check network connectivity and server status"
1286
+ ) from None
1287
+
1288
+ # Add a delay to allow for key sharing to complete
1289
+ # This addresses a race condition where the client attempts to send encrypted messages
1290
+ # before it has received and processed room key sharing messages from other devices.
1291
+ # The initial sync() call triggers key sharing requests, but the actual key exchange
1292
+ # happens asynchronously. Without this delay, outgoing messages may be sent unencrypted
1293
+ # even to encrypted rooms. While not ideal, this timing-based approach is necessary
1294
+ # because matrix-nio doesn't provide event-driven alternatives to detect when key
1295
+ # sharing is complete.
1296
+ if e2ee_enabled:
1297
+ logger.debug(
1298
+ f"Waiting for {E2EE_KEY_SHARING_DELAY_SECONDS} seconds to allow for key sharing..."
1299
+ )
1300
+ await asyncio.sleep(E2EE_KEY_SHARING_DELAY_SECONDS)
1301
+
1302
+ # Fetch the bot's display name
1303
+ response = await matrix_client.get_displayname(bot_user_id)
1304
+ if hasattr(response, "displayname"):
1305
+ bot_user_name = response.displayname
1306
+ else:
1307
+ bot_user_name = bot_user_id # Fallback if display name is not set
1308
+
1309
+ # Set E2EE status on the client for other functions to access
1310
+ matrix_client.e2ee_enabled = e2ee_enabled
1311
+
1312
+ return matrix_client
1313
+
1314
+
1315
+ async def login_matrix_bot(
1316
+ homeserver=None, username=None, password=None, logout_others=False
1317
+ ):
1318
+ """
1319
+ Perform an interactive Matrix login for the bot and persist credentials for later use.
1320
+
1321
+ This coroutine attempts server discovery for the provided homeserver, logs in as the given username, optionally initializes an encrypted client store (if E2EE is enabled in configuration), and saves resulting credentials (homeserver, user_id, access_token, device_id) to credentials.json so the relay can restore the session non-interactively. If an existing credentials.json contains a matching user_id, the device_id will be reused when available.
1322
+
1323
+ Parameters:
1324
+ homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
1325
+ username (str | None): Matrix username (without or with leading "@"). If None, the user is prompted.
1326
+ password (str | None): Password for the account. If None, the user is prompted securely.
1327
+ 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.)
1328
+
1329
+ Returns:
1330
+ bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
1331
+ """
1332
+ try:
1333
+ # Optionally enable verbose nio/aiohttp debug logging
1334
+ if os.getenv("MMRELAY_DEBUG_NIO") == "1":
1335
+ logging.getLogger("nio").setLevel(logging.DEBUG)
1336
+ logging.getLogger("nio.client").setLevel(logging.DEBUG)
1337
+ logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
1338
+ logging.getLogger("nio.responses").setLevel(logging.DEBUG)
1339
+ logging.getLogger("aiohttp").setLevel(logging.DEBUG)
1340
+
1341
+ # Get homeserver URL
1342
+ if not homeserver:
1343
+ homeserver = input(
1344
+ "Enter Matrix homeserver URL (e.g., https://matrix.org): "
1345
+ )
1346
+
1347
+ # Ensure homeserver URL has the correct format
1348
+ if not (homeserver.startswith("https://") or homeserver.startswith("http://")):
1349
+ homeserver = "https://" + homeserver
1350
+
1351
+ # Step 1: Perform server discovery to get the actual homeserver URL
1352
+ logger.info(f"Performing server discovery for {homeserver}...")
1353
+
1354
+ # Create SSL context using certifi's certificates
1355
+ ssl_context = _create_ssl_context()
1356
+ if ssl_context is None:
1357
+ logger.warning(
1358
+ "Failed to create SSL context for server discovery; falling back to default system SSL"
1359
+ )
1360
+ else:
1361
+ logger.debug(f"SSL context created successfully: {ssl_context}")
1362
+ logger.debug(f"SSL context protocol: {ssl_context.protocol}")
1363
+ logger.debug(f"SSL context verify_mode: {ssl_context.verify_mode}")
1364
+
1365
+ # Create a temporary client for discovery
1366
+ temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
1367
+ try:
1368
+ discovery_response = await asyncio.wait_for(
1369
+ temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
1370
+ )
1371
+
1372
+ try:
1373
+ if isinstance(discovery_response, DiscoveryInfoResponse):
1374
+ actual_homeserver = discovery_response.homeserver_url
1375
+ logger.info(f"Server discovery successful: {actual_homeserver}")
1376
+ homeserver = actual_homeserver
1377
+ elif isinstance(discovery_response, DiscoveryInfoError):
1378
+ logger.info(
1379
+ f"Server discovery failed, using original URL: {homeserver}"
1380
+ )
1381
+ # Continue with original homeserver URL
1382
+ else:
1383
+ # Fallback for test environments or unexpected response types
1384
+ if hasattr(discovery_response, "homeserver_url"):
1385
+ actual_homeserver = discovery_response.homeserver_url
1386
+ logger.info(f"Server discovery successful: {actual_homeserver}")
1387
+ homeserver = actual_homeserver
1388
+ else:
1389
+ logger.warning(
1390
+ f"Server discovery returned unexpected response type, using original URL: {homeserver}"
1391
+ )
1392
+ except TypeError as e:
1393
+ logger.warning(
1394
+ f"Server discovery error: {e}, using original URL: {homeserver}"
1395
+ )
1396
+
1397
+ except asyncio.TimeoutError:
1398
+ logger.warning(
1399
+ f"Server discovery timed out, using original URL: {homeserver}"
1400
+ )
1401
+ # Continue with original homeserver URL
1402
+ except Exception as e:
1403
+ logger.warning(
1404
+ f"Server discovery error: {e}, using original URL: {homeserver}"
1405
+ )
1406
+ # Continue with original homeserver URL
1407
+ finally:
1408
+ await temp_client.close()
1409
+
1410
+ # Get username
1411
+ if not username:
1412
+ username = input("Enter Matrix username (without @): ")
1413
+
1414
+ # Format username correctly
1415
+ username = _normalize_bot_user_id(homeserver, username)
1416
+
1417
+ logger.info(f"Using username: {username}")
1418
+
1419
+ # Validate username format
1420
+ if not username.startswith("@"):
1421
+ logger.warning(f"Username doesn't start with @: {username}")
1422
+ if username.count(":") != 1:
1423
+ logger.warning(
1424
+ f"Username has unexpected colon count: {username.count(':')}"
1425
+ )
1426
+
1427
+ # Check for special characters in username that might cause issues
1428
+ username_special_chars = set(username) - set(
1429
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@:.-_"
1430
+ )
1431
+ if username_special_chars:
1432
+ logger.warning(
1433
+ f"Username contains unusual characters: {username_special_chars}"
1434
+ )
1435
+
1436
+ # Get password
1437
+ if not password:
1438
+ password = getpass.getpass("Enter Matrix password: ")
1439
+
1440
+ # Simple password validation without logging sensitive information
1441
+ if password:
1442
+ logger.debug("Password provided for login")
1443
+ else:
1444
+ logger.warning("No password provided")
1445
+
1446
+ # Ask about logging out other sessions
1447
+ if logout_others is None:
1448
+ logout_others_input = input(
1449
+ "Log out other sessions? (Y/n) [Default: Yes]: "
1450
+ ).lower()
1451
+ logout_others = (
1452
+ not logout_others_input.startswith("n") if logout_others_input else True
1453
+ )
1454
+
1455
+ # Check for existing credentials to reuse device_id
1456
+ existing_device_id = None
1457
+ try:
1458
+ import json
1459
+
1460
+ config_dir = get_base_dir()
1461
+ credentials_path = os.path.join(config_dir, "credentials.json")
1462
+
1463
+ if os.path.exists(credentials_path):
1464
+ with open(credentials_path, "r", encoding="utf-8") as f:
1465
+ existing_creds = json.load(f)
1466
+ if (
1467
+ "device_id" in existing_creds
1468
+ and existing_creds["user_id"] == username
1469
+ ):
1470
+ existing_device_id = existing_creds["device_id"]
1471
+ logger.info(f"Reusing existing device_id: {existing_device_id}")
1472
+ except Exception as e:
1473
+ logger.debug(f"Could not load existing credentials: {e}")
1474
+
1475
+ # Check if E2EE is enabled in configuration
1476
+ from mmrelay.config import is_e2ee_enabled, load_config
1477
+
1478
+ try:
1479
+ config = load_config()
1480
+ e2ee_enabled = is_e2ee_enabled(config)
1481
+ except Exception as e:
1482
+ logger.debug(f"Could not load config for E2EE check: {e}")
1483
+ e2ee_enabled = False
1484
+
1485
+ logger.debug(f"E2EE enabled in config: {e2ee_enabled}")
1486
+
1487
+ # Get the E2EE store path only if E2EE is enabled
1488
+ store_path = None
1489
+ if e2ee_enabled:
1490
+ store_path = get_e2ee_store_dir()
1491
+ os.makedirs(store_path, exist_ok=True)
1492
+ logger.debug(f"Using E2EE store path: {store_path}")
1493
+ else:
1494
+ logger.debug("E2EE disabled in configuration, not using store path")
1495
+
1496
+ # Create client config with E2EE based on configuration
1497
+ client_config = AsyncClientConfig(
1498
+ store_sync_tokens=True, encryption_enabled=e2ee_enabled
1499
+ )
1500
+
1501
+ # Use the same SSL context as discovery client
1502
+ # ssl_context was created above for discovery
1503
+
1504
+ # Initialize client with E2EE support
1505
+ # Use most common pattern from matrix-nio examples: positional homeserver and user
1506
+ logger.debug("Creating AsyncClient with:")
1507
+ logger.debug(f" homeserver: {homeserver}")
1508
+ logger.debug(f" username: {username}")
1509
+ logger.debug(f" device_id: {existing_device_id}")
1510
+ logger.debug(f" store_path: {store_path}")
1511
+ logger.debug(f" e2ee_enabled: {e2ee_enabled}")
1512
+
1513
+ client = AsyncClient(
1514
+ homeserver,
1515
+ username,
1516
+ device_id=existing_device_id,
1517
+ store_path=store_path,
1518
+ config=client_config,
1519
+ ssl=ssl_context,
1520
+ )
1521
+
1522
+ logger.debug("AsyncClient created successfully")
1523
+
1524
+ logger.info(f"Logging in as {username} to {homeserver}...")
1525
+
1526
+ # Login with consistent device name and timeout
1527
+ # Use appropriate device name based on E2EE configuration
1528
+ device_name = "mmrelay-e2ee" if e2ee_enabled else "mmrelay"
1529
+ try:
1530
+ # Set device_id on client if we have an existing one
1531
+ if existing_device_id:
1532
+ client.device_id = existing_device_id
1533
+
1534
+ logger.debug(f"Attempting login to {homeserver} as {username}")
1535
+ logger.debug("Login parameters:")
1536
+ logger.debug(f" device_name: {device_name}")
1537
+ logger.debug(f" password length: {len(password) if password else 0}")
1538
+ logger.debug(f" client.user: {client.user}")
1539
+ logger.debug(f" client.homeserver: {client.homeserver}")
1540
+
1541
+ # Test the API call that matrix-nio will make
1542
+ try:
1543
+ from nio.api import Api
1544
+
1545
+ method, path, data = Api.login(
1546
+ user=username,
1547
+ password=password,
1548
+ device_name=device_name,
1549
+ device_id=existing_device_id,
1550
+ )
1551
+ logger.debug("Matrix API call details:")
1552
+ logger.debug(f" method: {method}")
1553
+ logger.debug(f" path: {path}")
1554
+ logger.debug(f" data length: {len(data) if data else 0}")
1555
+
1556
+ # Parse the JSON to see the structure (without logging the password)
1557
+ import json
1558
+
1559
+ parsed_data = json.loads(data)
1560
+ safe_data = {
1561
+ k: (v if k != "password" else f"[{len(v)} chars]")
1562
+ for k, v in parsed_data.items()
1563
+ }
1564
+ logger.debug(f" parsed data: {safe_data}")
1565
+
1566
+ except Exception as e:
1567
+ logger.error(f"Failed to test API call: {e}")
1568
+
1569
+ response = await asyncio.wait_for(
1570
+ client.login(password, device_name=device_name),
1571
+ timeout=MATRIX_LOGIN_TIMEOUT,
1572
+ )
1573
+
1574
+ # Debug: Log the response type and safe attributes only
1575
+ logger.debug(f"Login response type: {type(response).__name__}")
1576
+
1577
+ # Check specific attributes that should be present, masking sensitive data
1578
+ for attr in [
1579
+ "user_id",
1580
+ "device_id",
1581
+ "access_token",
1582
+ "status_code",
1583
+ "message",
1584
+ ]:
1585
+ if hasattr(response, attr):
1586
+ value = getattr(response, attr)
1587
+ if attr == "access_token" and value:
1588
+ # Mask access token for security
1589
+ masked_value = (
1590
+ f"{value[:8]}...{value[-4:]}"
1591
+ if len(value) > 12
1592
+ else "***masked***"
1593
+ )
1594
+ logger.debug(
1595
+ f"Response.{attr}: {masked_value} (type: {type(value).__name__})"
1596
+ )
1597
+ else:
1598
+ logger.debug(
1599
+ f"Response.{attr}: {value} (type: {type(value).__name__})"
1600
+ )
1601
+ else:
1602
+ logger.debug(f"Response.{attr}: NOT PRESENT")
1603
+ except asyncio.TimeoutError:
1604
+ logger.exception(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
1605
+ logger.error(
1606
+ "This may indicate network connectivity issues or a slow Matrix server"
1607
+ )
1608
+ await client.close()
1609
+ return False
1610
+ except TypeError as e:
1611
+ # Handle the specific ">=" comparison error that can occur in matrix-nio
1612
+ if "'>=' not supported between instances of 'str' and 'int'" in str(e):
1613
+ logger.error("Matrix-nio library error during login (known issue)")
1614
+ logger.error(
1615
+ "This typically indicates invalid credentials or server response format issues"
1616
+ )
1617
+ logger.error("Troubleshooting steps:")
1618
+ logger.error("1. Verify your username and password are correct")
1619
+ logger.error("2. Check if your account is locked or suspended")
1620
+ logger.error("3. Try logging in through a web browser first")
1621
+ logger.error("4. Ensure your Matrix server supports the login API")
1622
+ logger.error(
1623
+ "5. Try using a different homeserver URL format (e.g., with https://)"
1624
+ )
1625
+ else:
1626
+ logger.exception("Type error during login")
1627
+ await client.close()
1628
+ return False
1629
+ except Exception as e:
1630
+ # Handle other exceptions during login (e.g., network errors)
1631
+ error_type = type(e).__name__
1632
+ logger.exception(f"Login failed with {error_type}")
1633
+
1634
+ # Provide specific guidance based on error type
1635
+ if isinstance(e, (ConnectionError, asyncio.TimeoutError)):
1636
+ logger.error("Network connectivity issue detected.")
1637
+ logger.error("Troubleshooting steps:")
1638
+ logger.error("1. Check your internet connection")
1639
+ logger.error(f"2. Verify the homeserver URL is correct: {homeserver}")
1640
+ logger.error("3. Check if the Matrix server is online")
1641
+ elif isinstance(e, (ssl.SSLError, ssl.CertificateError)):
1642
+ logger.error("SSL/TLS certificate issue detected.")
1643
+ logger.error(
1644
+ "This may indicate a problem with the server's SSL certificate."
1645
+ )
1646
+ elif "DNSError" in error_type or "NameResolutionError" in error_type:
1647
+ logger.error("DNS resolution failed.")
1648
+ logger.error(f"Cannot resolve hostname: {homeserver}")
1649
+ logger.error("Check your DNS settings and internet connection.")
1650
+ elif "'user_id' is a required property" in str(e):
1651
+ logger.error("Matrix server response validation failed.")
1652
+ logger.error("This typically indicates:")
1653
+ logger.error("1. Invalid username or password")
1654
+ logger.error("2. Server response format not as expected")
1655
+ logger.error("3. Matrix server compatibility issues")
1656
+ logger.error("Troubleshooting steps:")
1657
+ logger.error("1. Verify credentials by logging in via web browser")
1658
+ logger.error(
1659
+ "2. Try using the full homeserver URL (e.g., https://matrix.org)"
1660
+ )
1661
+ logger.error(
1662
+ "3. Check if your Matrix server is compatible with matrix-nio"
1663
+ )
1664
+ logger.error("4. Try a different Matrix server if available")
1665
+
1666
+ else:
1667
+ logger.error("Unexpected error during login.")
1668
+
1669
+ # Additional details already included in the message above.
1670
+ await client.close()
1671
+ return False
1672
+
1673
+ # Handle login response - check for access_token first (most reliable indicator)
1674
+ if hasattr(response, "access_token") and response.access_token:
1675
+ logger.info("Login successful!")
1676
+
1677
+ # Get the actual user_id from whoami() - this is the proper way
1678
+ try:
1679
+ whoami_response = await client.whoami()
1680
+ if hasattr(whoami_response, "user_id"):
1681
+ actual_user_id = whoami_response.user_id
1682
+ logger.debug(f"Got user_id from whoami: {actual_user_id}")
1683
+ else:
1684
+ # Fallback to response user_id or username
1685
+ actual_user_id = getattr(response, "user_id", username)
1686
+ logger.warning(
1687
+ f"whoami failed, using fallback user_id: {actual_user_id}"
1688
+ )
1689
+ except Exception as e:
1690
+ logger.warning(f"whoami call failed: {e}, using fallback")
1691
+ actual_user_id = getattr(response, "user_id", username)
1692
+
1693
+ # Save credentials to credentials.json
1694
+ credentials = {
1695
+ "homeserver": homeserver,
1696
+ "user_id": actual_user_id,
1697
+ "access_token": response.access_token,
1698
+ "device_id": getattr(response, "device_id", existing_device_id),
1699
+ }
1700
+
1701
+ config_dir = get_base_dir()
1702
+ credentials_path = os.path.join(config_dir, "credentials.json")
1703
+ save_credentials(credentials)
1704
+ logger.info(f"Credentials saved to {credentials_path}")
1705
+
1706
+ # Logout other sessions if requested
1707
+ if logout_others:
1708
+ logger.info("Logging out other sessions...")
1709
+ # Note: This would require additional implementation
1710
+ logger.warning("Logout others not yet implemented")
1711
+
1712
+ await client.close()
1713
+ return True
1714
+ else:
1715
+ # Handle login failure
1716
+ if hasattr(response, "status_code") and hasattr(response, "message"):
1717
+ status_code = response.status_code
1718
+ error_message = response.message
1719
+
1720
+ logger.error(f"Login failed: {type(response).__name__}")
1721
+ logger.error(f"Error message: {error_message}")
1722
+ logger.error(f"HTTP status code: {status_code}")
1723
+
1724
+ # Provide specific troubleshooting guidance
1725
+ if status_code == 401 or "M_FORBIDDEN" in str(error_message):
1726
+ logger.error(
1727
+ "Authentication failed - invalid username or password."
1728
+ )
1729
+ logger.error("Troubleshooting steps:")
1730
+ logger.error("1. Verify your username and password are correct")
1731
+ logger.error("2. Check if your account is locked or suspended")
1732
+ logger.error("3. Try logging in through a web browser first")
1733
+ logger.error(
1734
+ "4. Use 'mmrelay auth login' to set up new credentials"
1735
+ )
1736
+ elif status_code == 404:
1737
+ logger.error("User not found or homeserver not found.")
1738
+ logger.error(
1739
+ f"Check that the homeserver URL is correct: {homeserver}"
1740
+ )
1741
+ elif status_code == 429:
1742
+ logger.error("Rate limited - too many login attempts.")
1743
+ logger.error("Wait a few minutes before trying again.")
1744
+ elif status_code and int(status_code) >= 500:
1745
+ logger.error(
1746
+ "Matrix server error - the server is experiencing issues."
1747
+ )
1748
+ logger.error(
1749
+ "Try again later or contact your server administrator."
1750
+ )
1751
+ else:
1752
+ logger.error("Login failed for unknown reason.")
1753
+ logger.error(
1754
+ "Try using 'mmrelay auth login' for interactive setup."
1755
+ )
1756
+ else:
1757
+ logger.error(f"Unexpected login response: {type(response).__name__}")
1758
+ logger.error(
1759
+ "This may indicate a matrix-nio library issue or server problem."
1760
+ )
1761
+
1762
+ await client.close()
1763
+ return False
1764
+
1765
+ except Exception:
1766
+ logger.exception("Error during login")
1767
+ try:
1768
+ await client.close()
1769
+ except Exception as e:
1770
+ # Ignore errors during client cleanup - connection may already be closed
1771
+ logger.debug(f"Ignoring error during client cleanup: {e}")
1772
+ return False
1773
+
1774
+
1775
+ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
1776
+ """
1777
+ Join the bot to a Matrix room by ID or alias.
1778
+
1779
+ Resolves a room alias (e.g. "#room:server") to its canonical room ID, updates the in-memory
1780
+ matrix_rooms mapping with the resolved ID (if available), and attempts to join the resolved
1781
+ room ID. No-op if the client is already joined to the room. Errors during alias resolution
1782
+ or join are caught and logged; the function does not raise exceptions.
1783
+
1784
+ Parameters documented only where meaning is not obvious:
1785
+ room_id_or_alias (str): A Matrix room identifier, either a canonical room ID (e.g. "!abc:server")
1786
+ or a room alias (starts with '#'). When an alias is provided, it will be resolved and
1787
+ the resolved room ID will be used for joining and recorded in the module's matrix_rooms mapping.
1788
+
1789
+ Returns:
1790
+ None
1791
+ """
1792
+
1793
+ if not isinstance(room_id_or_alias, str):
1794
+ logger.error(
1795
+ "join_matrix_room expected a string room ID, received %r",
1796
+ room_id_or_alias,
1797
+ )
1798
+ return
1799
+
1800
+ room_id = room_id_or_alias
1801
+
1802
+ if room_id_or_alias.startswith("#"):
1803
+ try:
1804
+ response = await matrix_client.room_resolve_alias(room_id_or_alias)
1805
+ except (
1806
+ NioLocalProtocolError,
1807
+ NioRemoteProtocolError,
1808
+ NioErrorResponse,
1809
+ NioLocalTransportError,
1810
+ NioRemoteTransportError,
1811
+ asyncio.TimeoutError,
1812
+ ):
1813
+ logger.exception("Error resolving alias '%s'", room_id_or_alias)
1814
+ return
1815
+
1816
+ room_id = getattr(response, "room_id", None) if response else None
1817
+ if not room_id:
1818
+ logger.error(
1819
+ "Failed to resolve alias '%s': %s",
1820
+ room_id_or_alias,
1821
+ getattr(response, "message", str(response)),
1822
+ )
1823
+ return
1824
+
1825
+ try:
1826
+ mapping = matrix_rooms
1827
+ except NameError:
1828
+ mapping = None
1829
+
1830
+ if mapping:
1831
+ try:
1832
+ _update_room_id_in_mapping(mapping, room_id_or_alias, room_id)
1833
+ except Exception:
1834
+ logger.debug(
1835
+ "Non-fatal error updating matrix_rooms for alias '%s'",
1836
+ room_id_or_alias,
1837
+ exc_info=True,
1838
+ )
1839
+
1840
+ logger.info("Resolved alias '%s' -> '%s'", room_id_or_alias, room_id)
1841
+
1842
+ try:
1843
+ if room_id not in matrix_client.rooms:
1844
+ response = await matrix_client.join(room_id)
1845
+ joined_room_id = getattr(response, "room_id", None) if response else None
1846
+ if joined_room_id:
1847
+ logger.info(f"Joined room '{joined_room_id}' successfully")
1848
+ else:
1849
+ logger.error(
1850
+ "Failed to join room '%s': %s",
1851
+ room_id,
1852
+ getattr(response, "message", str(response)),
1853
+ )
1854
+ else:
1855
+ logger.debug(
1856
+ "Bot is already in room '%s', no action needed.",
1857
+ room_id,
1858
+ )
1859
+ except (
1860
+ NioLocalProtocolError,
1861
+ NioRemoteProtocolError,
1862
+ NioErrorResponse,
1863
+ NioLocalTransportError,
1864
+ NioRemoteTransportError,
1865
+ asyncio.TimeoutError,
1866
+ ):
1867
+ logger.exception(f"Error joining room '{room_id}'")
1868
+
1869
+
1870
+ def _get_e2ee_error_message():
1871
+ """
1872
+ Return a user-facing string explaining why End-to-End Encryption (E2EE) is not enabled.
1873
+
1874
+ This queries the unified E2EE status (using the module-level config and config path)
1875
+ and converts that status into a concise error message suitable for logging or UI display.
1876
+
1877
+ Returns:
1878
+ str: A short, human-readable explanation of the current E2EE problem (empty or generic
1879
+ message if no specific issue is detected).
1880
+ """
1881
+ from mmrelay.config import config_path
1882
+ from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
1883
+
1884
+ # Get unified E2EE status
1885
+ e2ee_status = get_e2ee_status(config, config_path)
1886
+
1887
+ # Return unified error message
1888
+ return get_e2ee_error_message(e2ee_status)
1889
+
1890
+
1891
+ async def matrix_relay(
1892
+ room_id,
1893
+ message,
1894
+ longname,
1895
+ shortname,
1896
+ meshnet_name,
1897
+ portnum,
1898
+ meshtastic_id=None,
1899
+ meshtastic_replyId=None,
1900
+ meshtastic_text=None,
1901
+ emote=False,
1902
+ emoji=False,
1903
+ reply_to_event_id=None,
1904
+ ):
1905
+ """
1906
+ Relay a Meshtastic message into a Matrix room and optionally persist a Meshtastic⇄Matrix mapping for later interactions.
1907
+
1908
+ Formats the Meshtastic text into plain and HTML-safe formatted Matrix content, applies Matrix reply framing when reply_to_event_id is provided, enforces E2EE restrictions, sends the event via the configured Matrix client, and—when message-interactions are enabled—stores a mapping from the Meshtastic message to the created Matrix event for use by cross-network replies and reactions. Errors are logged; the function does not raise on send or storage failures.
1909
+
1910
+ Parameters:
1911
+ room_id (str): Matrix room ID or alias to send the message into.
1912
+ message (str): Text of the Meshtastic message to relay.
1913
+ longname (str): Sender long display name from Meshtastic.
1914
+ shortname (str): Sender short display name from Meshtastic.
1915
+ meshnet_name (str): Remote meshnet name associated with the incoming message.
1916
+ portnum (int): Meshtastic application/port number for the message.
1917
+ meshtastic_id (optional): Meshtastic message identifier; when provided and storage is enabled, used to persist a mapping to the created Matrix event.
1918
+ meshtastic_replyId (optional): Original Meshtastic message ID being replied to; included as metadata on the Matrix event.
1919
+ meshtastic_text (optional): Original Meshtastic text to store with the mapping; if omitted, the relayed message text is used.
1920
+ emote (bool, optional): If True, send the Matrix event as `m.emote` instead of `m.text`.
1921
+ emoji (bool, optional): If True, mark the event with an emoji flag used by downstream logic.
1922
+ reply_to_event_id (str, optional): Matrix event_id being replied to; if provided the outgoing event will include an `m.in_reply_to` relation and quoted/HTML reply content when the original mapping can be found.
1923
+ """
1924
+ global config
1925
+
1926
+ # Log the current state of the config
1927
+ logger.debug(f"matrix_relay: config is {'available' if config else 'None'}")
1928
+
1929
+ matrix_client = await connect_matrix()
1930
+
1931
+ # Check if config is available
1932
+ if config is None:
1933
+ logger.error("No configuration available. Cannot relay message to Matrix.")
1934
+ return
1935
+
1936
+ # Get interaction settings
1937
+ interactions = get_interaction_settings(config)
1938
+ storage_enabled = message_storage_enabled(interactions)
1939
+
1940
+ # Retrieve db config for message_map pruning
1941
+ # Check database config for message map settings (preferred format)
1942
+ database_config = config.get("database", {})
1943
+ msg_map_config = database_config.get("msg_map", {})
1944
+
1945
+ # If not found in database config, check legacy db config
1946
+ if not msg_map_config:
1947
+ db_config = config.get("db", {})
1948
+ legacy_msg_map_config = db_config.get("msg_map", {})
1949
+
1950
+ if legacy_msg_map_config:
1951
+ msg_map_config = legacy_msg_map_config
1952
+ logger.warning(
1953
+ "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."
1954
+ )
1955
+ msgs_to_keep = msg_map_config.get(
1956
+ "msgs_to_keep", DEFAULT_MSGS_TO_KEEP
1957
+ ) # Default from constants
1958
+
1959
+ try:
1960
+ # Always use our own local meshnet_name for outgoing events
1961
+ local_meshnet_name = config["meshtastic"]["meshnet_name"]
1962
+
1963
+ # Check if message contains HTML tags or markdown formatting
1964
+ has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
1965
+ has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
1966
+
1967
+ # Process markdown/HTML if available; otherwise, safe fallback
1968
+ if has_markdown or has_html:
1969
+ try:
1970
+ import bleach # lazy import
1971
+ import markdown # lazy import
1972
+
1973
+ raw_html = markdown.markdown(message)
1974
+ formatted_body = bleach.clean(
1975
+ raw_html,
1976
+ tags=[
1977
+ "b",
1978
+ "strong",
1979
+ "i",
1980
+ "em",
1981
+ "code",
1982
+ "pre",
1983
+ "br",
1984
+ "blockquote",
1985
+ "a",
1986
+ "ul",
1987
+ "ol",
1988
+ "li",
1989
+ "p",
1990
+ ],
1991
+ attributes={"a": ["href"]},
1992
+ strip=True,
1993
+ )
1994
+ plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
1995
+ except ImportError:
1996
+ formatted_body = html.escape(message).replace("\n", "<br/>")
1997
+ plain_body = message
1998
+ else:
1999
+ formatted_body = html.escape(message).replace("\n", "<br/>")
2000
+ plain_body = message
2001
+
2002
+ content = {
2003
+ "msgtype": "m.text" if not emote else "m.emote",
2004
+ "body": plain_body,
2005
+ "meshtastic_longname": longname,
2006
+ "meshtastic_shortname": shortname,
2007
+ "meshtastic_meshnet": local_meshnet_name,
2008
+ "meshtastic_portnum": portnum,
2009
+ }
2010
+
2011
+ # Always add format and formatted_body to avoid nio validation errors
2012
+ # where formatted_body becomes None and fails schema validation.
2013
+ content["format"] = "org.matrix.custom.html"
2014
+ content["formatted_body"] = formatted_body
2015
+ if meshtastic_id is not None:
2016
+ content["meshtastic_id"] = meshtastic_id
2017
+ if meshtastic_replyId is not None:
2018
+ content["meshtastic_replyId"] = meshtastic_replyId
2019
+ if meshtastic_text is not None:
2020
+ content["meshtastic_text"] = meshtastic_text
2021
+ if emoji:
2022
+ content["meshtastic_emoji"] = 1
2023
+
2024
+ # Add Matrix reply formatting if this is a reply
2025
+ if reply_to_event_id:
2026
+ content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
2027
+ # For Matrix replies, we need to format the body with quoted content
2028
+ # Get the original message details for proper quoting
2029
+ try:
2030
+ orig = get_message_map_by_matrix_event_id(reply_to_event_id)
2031
+ if orig:
2032
+ # orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
2033
+ _, _, original_text, original_meshnet = orig
2034
+
2035
+ # Use the relay bot's user ID for attribution (this is correct for relay messages)
2036
+ bot_user_id = matrix_client.user_id
2037
+ original_sender_display = f"{longname}/{original_meshnet}"
2038
+
2039
+ # Create the quoted reply format
2040
+ safe_original = html.escape(original_text or "")
2041
+ safe_sender_display = re.sub(
2042
+ r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
2043
+ )
2044
+ quoted_text = (
2045
+ f"> <{bot_user_id}> [{safe_sender_display}]: {safe_original}"
2046
+ )
2047
+ content["body"] = f"{quoted_text}\n\n{plain_body}"
2048
+
2049
+ # Always use HTML formatting for replies since we need the mx-reply structure
2050
+ content["format"] = "org.matrix.custom.html"
2051
+ reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
2052
+ bot_link = f"https://matrix.to/#/{bot_user_id}"
2053
+ blockquote_content = (
2054
+ f'<a href="{reply_link}">In reply to</a> '
2055
+ f'<a href="{bot_link}">{bot_user_id}</a><br>'
2056
+ f"[{html.escape(original_sender_display)}]: {safe_original}"
2057
+ )
2058
+ content["formatted_body"] = (
2059
+ f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{formatted_body}"
2060
+ )
2061
+ else:
2062
+ logger.warning(
2063
+ f"Could not find original message for reply_to_event_id: {reply_to_event_id}"
2064
+ )
2065
+ except Exception as e:
2066
+ logger.error(f"Error formatting Matrix reply: {e}")
2067
+
2068
+ try:
2069
+ # Ensure matrix_client is not None
2070
+ if not matrix_client:
2071
+ logger.error("Matrix client is None. Cannot send message.")
2072
+ return
2073
+
2074
+ # Send the message with a timeout
2075
+ # For encrypted rooms, use ignore_unverified_devices=True
2076
+ # After checking working implementations, always use ignore_unverified_devices=True
2077
+ # for text messages to ensure encryption works properly
2078
+ room = (
2079
+ matrix_client.rooms.get(room_id)
2080
+ if matrix_client and hasattr(matrix_client, "rooms")
2081
+ else None
2082
+ )
2083
+
2084
+ # Debug logging for encryption status
2085
+ if room:
2086
+ encrypted_status = getattr(room, "encrypted", "unknown")
2087
+ logger.debug(
2088
+ f"Room {room_id} encryption status: encrypted={encrypted_status}"
2089
+ )
2090
+
2091
+ # Additional E2EE debugging
2092
+ if encrypted_status is True:
2093
+ logger.debug(f"Sending encrypted message to room {room_id}")
2094
+ elif encrypted_status is False:
2095
+ logger.debug(f"Sending unencrypted message to room {room_id}")
2096
+ else:
2097
+ logger.warning(
2098
+ f"Room {room_id} encryption status is unknown - this may indicate E2EE issues"
2099
+ )
2100
+ else:
2101
+ logger.warning(
2102
+ f"Room {room_id} not found in client.rooms - cannot determine encryption status"
2103
+ )
2104
+
2105
+ # Always use ignore_unverified_devices=True for text messages (like matrix-nio-send)
2106
+ logger.debug(
2107
+ "Sending message with ignore_unverified_devices=True (always for text messages)"
2108
+ )
2109
+
2110
+ # Final check: Do not send to encrypted rooms if E2EE is not enabled
2111
+ if (
2112
+ room
2113
+ and getattr(room, "encrypted", False)
2114
+ and not getattr(matrix_client, "e2ee_enabled", False)
2115
+ ):
2116
+ room_name = getattr(room, "display_name", room_id)
2117
+ error_message = _get_e2ee_error_message()
2118
+ logger.error(
2119
+ f"🔒 BLOCKED: Cannot send message to encrypted room '{room_name}' ({room_id})"
2120
+ )
2121
+ logger.error(f"Reason: {error_message}")
2122
+ logger.info(
2123
+ "💡 Tip: Run 'mmrelay config check' to validate your E2EE setup"
2124
+ )
2125
+ return
2126
+
2127
+ response = await asyncio.wait_for(
2128
+ matrix_client.room_send(
2129
+ room_id=room_id,
2130
+ message_type="m.room.message",
2131
+ content=content,
2132
+ ignore_unverified_devices=True,
2133
+ ),
2134
+ timeout=MATRIX_ROOM_SEND_TIMEOUT, # Increased timeout
2135
+ )
2136
+
2137
+ # Log at info level, matching one-point-oh pattern
2138
+ logger.info(f"Sent inbound radio message to matrix room: {room_id}")
2139
+ # Additional details at debug level
2140
+ if hasattr(response, "event_id"):
2141
+ logger.debug(f"Message event_id: {response.event_id}")
2142
+
2143
+ except asyncio.TimeoutError:
2144
+ logger.error(f"Timeout sending message to Matrix room {room_id}")
2145
+ return
2146
+ except Exception:
2147
+ logger.exception(f"Error sending message to Matrix room {room_id}")
2148
+ return
2149
+
2150
+ # Only store message map if any interactions are enabled and conditions are met
2151
+ # This enables reactions and/or replies functionality based on configuration
2152
+ if (
2153
+ storage_enabled
2154
+ and meshtastic_id is not None
2155
+ and not emote
2156
+ and hasattr(response, "event_id")
2157
+ ):
2158
+ try:
2159
+ await async_store_message_map(
2160
+ meshtastic_id,
2161
+ response.event_id,
2162
+ room_id,
2163
+ meshtastic_text if meshtastic_text else message,
2164
+ meshtastic_meshnet=local_meshnet_name,
2165
+ )
2166
+ logger.debug(f"Stored message map for meshtastic_id: {meshtastic_id}")
2167
+
2168
+ # If msgs_to_keep > 0, prune old messages after inserting a new one
2169
+ if msgs_to_keep > 0:
2170
+ await async_prune_message_map(msgs_to_keep)
2171
+ except Exception as e:
2172
+ logger.error(f"Error storing message map: {e}")
2173
+
2174
+ except asyncio.TimeoutError:
2175
+ logger.error("Timed out while waiting for Matrix response")
2176
+ except Exception:
2177
+ logger.exception(f"Error sending radio message to matrix room {room_id}")
2178
+
2179
+
2180
+ def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
2181
+ """
2182
+ Truncate a string so its UTF-8 encoding fits within max_bytes.
2183
+
2184
+ Returns a substring whose UTF-8 byte length is at most `max_bytes`. If
2185
+ `max_bytes` falls in the middle of a multi-byte UTF-8 character, the
2186
+ incomplete character is dropped (decoding uses 'ignore').
2187
+
2188
+ Parameters:
2189
+ text (str): Input text to truncate.
2190
+ max_bytes (int): Maximum allowed size in bytes for the UTF-8 encoded result
2191
+ (defaults to DEFAULT_MESSAGE_TRUNCATE_BYTES).
2192
+
2193
+ Returns:
2194
+ str: Truncated string.
2195
+ """
2196
+ truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
2197
+ return truncated_text
2198
+
2199
+
2200
+ def strip_quoted_lines(text: str) -> str:
2201
+ """
2202
+ Removes lines starting with '>' from the input text.
2203
+
2204
+ This is typically used to exclude quoted content from Matrix replies, such as when processing reaction text.
2205
+ """
2206
+ lines = text.splitlines()
2207
+ filtered = [line.strip() for line in lines if not line.strip().startswith(">")]
2208
+ return " ".join(line for line in filtered if line).strip()
2209
+
2210
+
2211
+ async def get_user_display_name(room, event):
2212
+ """
2213
+ Return the display name for the event sender, preferring a room-specific name.
2214
+
2215
+ If the room provides a per-room display name for the sender, that name is returned.
2216
+ Otherwise the function performs an asynchronous lookup against the homeserver for the
2217
+ user's global display name and returns it if present. If no display name is available,
2218
+ the sender's Matrix ID (MXID) is returned.
2219
+
2220
+ Returns:
2221
+ str: A human-readable display name or the sender's MXID.
2222
+ """
2223
+ room_display_name = room.user_name(event.sender)
2224
+ if room_display_name:
2225
+ return room_display_name
2226
+
2227
+ display_name_response = await matrix_client.get_displayname(event.sender)
2228
+ return display_name_response.displayname or event.sender
2229
+
2230
+
2231
+ def format_reply_message(
2232
+ config,
2233
+ full_display_name,
2234
+ text,
2235
+ *,
2236
+ longname=None,
2237
+ shortname=None,
2238
+ meshnet_name=None,
2239
+ local_meshnet_name=None,
2240
+ mesh_text_override=None,
2241
+ ):
2242
+ """
2243
+ Format a reply message by prefixing a truncated display name and removing quoted lines.
2244
+
2245
+ 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.
2246
+
2247
+ Parameters:
2248
+ full_display_name (str): The user's full display name to be truncated for the prefix.
2249
+ text (str): The reply text, possibly containing quoted lines.
2250
+
2251
+ Returns:
2252
+ str: The formatted and truncated reply message.
2253
+ """
2254
+ # Determine the base text to use (prefer the raw Meshtastic payload when present)
2255
+ base_text = mesh_text_override if mesh_text_override else text
2256
+
2257
+ clean_text = strip_quoted_lines(base_text).strip()
2258
+
2259
+ # Handle remote meshnet replies by using the remote sender's prefix format
2260
+ if meshnet_name and local_meshnet_name and meshnet_name != local_meshnet_name:
2261
+ sender_long = longname or full_display_name or shortname or "???"
2262
+ sender_short = shortname or sender_long[:SHORTNAME_FALLBACK_LENGTH] or "???"
2263
+ short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
2264
+
2265
+ prefix_candidates = [
2266
+ f"[{sender_long}/{meshnet_name}]: ",
2267
+ f"[{sender_long}/{short_meshnet_name}]: ",
2268
+ f"{sender_long}/{meshnet_name}: ",
2269
+ f"{sender_long}/{short_meshnet_name}: ",
2270
+ f"{sender_short}/{meshnet_name}: ",
2271
+ f"{sender_short}/{short_meshnet_name}: ",
2272
+ ]
2273
+
2274
+ matrix_prefix_full = get_matrix_prefix(
2275
+ config, sender_long, sender_short, meshnet_name
2276
+ )
2277
+ matrix_prefix_short = get_matrix_prefix(
2278
+ config, sender_long, sender_short, short_meshnet_name
2279
+ )
2280
+ prefix_candidates.extend([matrix_prefix_full, matrix_prefix_short])
2281
+
2282
+ for candidate in prefix_candidates:
2283
+ if candidate and clean_text.startswith(candidate):
2284
+ clean_text = clean_text[len(candidate) :].lstrip()
2285
+ break
2286
+
2287
+ if not clean_text and mesh_text_override:
2288
+ clean_text = strip_quoted_lines(mesh_text_override).strip()
2289
+
2290
+ mesh_prefix = f"{sender_short}/{short_meshnet_name}:"
2291
+ reply_body = f" {clean_text}" if clean_text else ""
2292
+ reply_message = f"{mesh_prefix}{reply_body}"
2293
+ return truncate_message(reply_message.strip())
2294
+
2295
+ # Default behavior for local Matrix users (retain existing prefix logic)
2296
+ prefix = get_meshtastic_prefix(config, full_display_name)
2297
+ reply_message = f"{prefix}{clean_text}" if clean_text else prefix.rstrip()
2298
+ return truncate_message(reply_message)
2299
+
2300
+
2301
+ async def send_reply_to_meshtastic(
2302
+ reply_message,
2303
+ full_display_name,
2304
+ room_config,
2305
+ room,
2306
+ event,
2307
+ text,
2308
+ storage_enabled,
2309
+ local_meshnet_name,
2310
+ reply_id=None,
2311
+ ):
2312
+ """
2313
+ Enqueue a Matrix reply to be delivered over Meshtastic as either a structured reply or a regular broadcast.
2314
+
2315
+ If broadcasting is disabled this function does nothing. When storage_enabled is True, it constructs a mapping record that links the originating Matrix event to the Meshtastic message and attaches it to the queued message so replies and reactions can be correlated later. Errors are logged; the function does not raise.
2316
+
2317
+ Parameters:
2318
+ reply_message (str): Text payload already formatted for Meshtastic.
2319
+ full_display_name (str): Sender display name used in queue descriptions and logs.
2320
+ room_config (dict): Room-specific configuration; must contain "meshtastic_channel" (integer channel index).
2321
+ room: Matrix room object; its room_id is used for mapping metadata.
2322
+ event: Matrix event object; its event_id is used for mapping metadata.
2323
+ text (str): Original Matrix message text used when building mapping metadata.
2324
+ storage_enabled (bool): If True, create and attach a message-mapping record to the queued Meshtastic message.
2325
+ local_meshnet_name (str | None): Local meshnet name included in mapping metadata when present.
2326
+ reply_id (int | None): If provided, send as a structured Meshtastic reply targeting this Meshtastic message ID; otherwise send a regular broadcast.
2327
+ """
2328
+ loop = asyncio.get_running_loop()
2329
+ meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
2330
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
2331
+
2332
+ meshtastic_channel = room_config["meshtastic_channel"]
2333
+
2334
+ broadcast_enabled = get_meshtastic_config_value(
2335
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2336
+ )
2337
+ logger.debug(f"broadcast_enabled = {broadcast_enabled}")
2338
+
2339
+ if broadcast_enabled:
2340
+ try:
2341
+ # Create mapping info once if storage is enabled
2342
+ mapping_info = None
2343
+ if storage_enabled:
2344
+ # Get message map configuration
2345
+ msgs_to_keep = _get_msgs_to_keep_config()
2346
+
2347
+ mapping_info = _create_mapping_info(
2348
+ event.event_id, room.room_id, text, local_meshnet_name, msgs_to_keep
2349
+ )
2350
+
2351
+ if reply_id is not None:
2352
+ # Send as a structured reply using our custom function
2353
+ # Queue the reply message
2354
+ success = queue_message(
2355
+ sendTextReply,
2356
+ meshtastic_interface,
2357
+ text=reply_message,
2358
+ reply_id=reply_id,
2359
+ channelIndex=meshtastic_channel,
2360
+ description=f"Reply from {full_display_name} to message {reply_id}",
2361
+ mapping_info=mapping_info,
2362
+ )
2363
+
2364
+ if success:
2365
+ # Get queue size to determine logging approach
2366
+ queue_size = get_message_queue().get_queue_size()
2367
+
2368
+ if queue_size > 1:
2369
+ meshtastic_logger.info(
2370
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply (queued: {queue_size} messages)"
2371
+ )
2372
+ else:
2373
+ meshtastic_logger.info(
2374
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply"
2375
+ )
2376
+ else:
2377
+ meshtastic_logger.error(
2378
+ "Failed to relay structured reply to Meshtastic"
2379
+ )
2380
+ return
2381
+ else:
2382
+ # Send as regular message (fallback for when no reply_id is available)
2383
+ success = queue_message(
2384
+ meshtastic_interface.sendText,
2385
+ text=reply_message,
2386
+ channelIndex=meshtastic_channel,
2387
+ description=f"Reply from {full_display_name} (fallback to regular message)",
2388
+ mapping_info=mapping_info,
2389
+ )
2390
+
2391
+ if success:
2392
+ # Get queue size to determine logging approach
2393
+ queue_size = get_message_queue().get_queue_size()
2394
+
2395
+ if queue_size > 1:
2396
+ meshtastic_logger.info(
2397
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
2398
+ )
2399
+ else:
2400
+ meshtastic_logger.info(
2401
+ f"Relaying Matrix reply from {full_display_name} to radio broadcast"
2402
+ )
2403
+ else:
2404
+ meshtastic_logger.error(
2405
+ "Failed to relay reply message to Meshtastic"
2406
+ )
2407
+ return
2408
+
2409
+ # Message mapping is now handled automatically by the queue system
2410
+
2411
+ except Exception:
2412
+ meshtastic_logger.exception("Error sending Matrix reply to Meshtastic")
2413
+
2414
+
2415
+ async def handle_matrix_reply(
2416
+ room,
2417
+ event,
2418
+ reply_to_event_id,
2419
+ text,
2420
+ room_config,
2421
+ storage_enabled,
2422
+ local_meshnet_name,
2423
+ config,
2424
+ *,
2425
+ mesh_text_override=None,
2426
+ longname=None,
2427
+ shortname=None,
2428
+ meshnet_name=None,
2429
+ ):
2430
+ """
2431
+ Forward a Matrix reply to Meshtastic when the replied-to Matrix event maps to a Meshtastic message.
2432
+
2433
+ If the Matrix event identified by reply_to_event_id has an associated Meshtastic mapping, format a Meshtastic reply that preserves sender attribution and enqueue it referencing the original Meshtastic message ID. If no mapping exists, do nothing.
2434
+
2435
+ Parameters:
2436
+ room: Matrix room object where the reply originated.
2437
+ event: Matrix event object representing the reply.
2438
+ reply_to_event_id (str): Matrix event ID being replied to; used to locate the Meshtastic mapping.
2439
+ text (str): The reply text from Matrix.
2440
+ room_config (dict): Per-room relay configuration used when sending to Meshtastic.
2441
+ storage_enabled (bool): Whether message mapping/storage is enabled.
2442
+ local_meshnet_name (str): Local meshnet name used to determine cross-meshnet formatting.
2443
+ config (dict): Global relay configuration passed to formatting routines.
2444
+ mesh_text_override (str | None): Optional override text to send instead of the derived text.
2445
+ longname (str | None): Sender long display name used for prefixing.
2446
+ shortname (str | None): Sender short display name used for prefixing.
2447
+ meshnet_name (str | None): Remote meshnet name associated with the original mapping, if any.
2448
+
2449
+ Returns:
2450
+ bool: `True` if a mapping was found and the reply was queued to Meshtastic, `False` otherwise.
2451
+ """
2452
+ # Look up the original message in the message map
2453
+ loop = asyncio.get_running_loop()
2454
+ orig = await loop.run_in_executor(
2455
+ None, get_message_map_by_matrix_event_id, reply_to_event_id
2456
+ )
2457
+ if not orig:
2458
+ logger.debug(
2459
+ f"Original message for Matrix reply not found in DB: {reply_to_event_id}"
2460
+ )
2461
+ return False # Continue processing as normal message if original not found
2462
+
2463
+ # Extract the original meshtastic_id to use as reply_id
2464
+ # orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
2465
+ original_meshtastic_id = orig[0]
2466
+
2467
+ # Get user display name
2468
+ full_display_name = await get_user_display_name(room, event)
2469
+
2470
+ # Format the reply message
2471
+ reply_message = format_reply_message(
2472
+ config,
2473
+ full_display_name,
2474
+ text,
2475
+ longname=longname,
2476
+ shortname=shortname,
2477
+ meshnet_name=meshnet_name,
2478
+ local_meshnet_name=local_meshnet_name,
2479
+ mesh_text_override=mesh_text_override,
2480
+ )
2481
+
2482
+ logger.info(
2483
+ f"Relaying Matrix reply from {full_display_name} to Meshtastic as reply to message {original_meshtastic_id}"
2484
+ )
2485
+
2486
+ # Send the reply to Meshtastic with the original message ID as reply_id
2487
+ await send_reply_to_meshtastic(
2488
+ reply_message,
2489
+ full_display_name,
2490
+ room_config,
2491
+ room,
2492
+ event,
2493
+ text,
2494
+ storage_enabled,
2495
+ local_meshnet_name,
2496
+ reply_id=original_meshtastic_id,
2497
+ )
2498
+
2499
+ return True # Reply was handled, stop further processing
2500
+
2501
+
2502
+ async def on_decryption_failure(room: MatrixRoom, event: MegolmEvent) -> None:
2503
+ """
2504
+ Handle a MegolmEvent that failed to decrypt by requesting the needed session keys.
2505
+
2506
+ 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:
2507
+ - Set event.room_id to the room's id (monkey-patch) so the key request is properly scoped.
2508
+ - Create a key request from the event and send it with matrix_client.to_device().
2509
+ - Log success or any errors encountered.
2510
+
2511
+ If the module-level Matrix client is not available, the function logs an error and returns without sending a request.
2512
+ """
2513
+ logger.error(
2514
+ f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'! "
2515
+ f"This is usually temporary and resolves on its own. "
2516
+ f"If this persists, the bot's session may be corrupt. "
2517
+ f"{msg_retry_auth_login()}."
2518
+ )
2519
+
2520
+ # Attempt to request the keys for the failed event
2521
+ try:
2522
+ if not matrix_client:
2523
+ logger.error("Matrix client not available, cannot request keys.")
2524
+ return
2525
+
2526
+ # Monkey-patch the event object with the correct room_id
2527
+ event.room_id = room.room_id
2528
+
2529
+ request = event.as_key_request(matrix_client.user_id, matrix_client.device_id)
2530
+ await matrix_client.to_device(request)
2531
+ logger.info(f"Requested keys for failed decryption of event {event.event_id}")
2532
+ except Exception:
2533
+ logger.exception(f"Failed to request keys for event {event.event_id}")
2534
+
2535
+
2536
+ # Callback for new messages in Matrix room
2537
+ async def on_room_message(
2538
+ room: MatrixRoom,
2539
+ event: Union[
2540
+ RoomMessageText,
2541
+ RoomMessageNotice,
2542
+ ReactionEvent,
2543
+ RoomMessageEmote,
2544
+ ],
2545
+ ) -> None:
2546
+ """
2547
+ Handle an incoming Matrix room event and relay it to Meshtastic when applicable.
2548
+
2549
+ Processes text, notice, emote, and reaction events for configured rooms: ignores events from before the bot started and events sent by the bot itself; respects per-room configuration and global interaction settings; routes reactions back to the originating Meshtastic message when a mapping exists (including forwarding remote-meshnet emote reactions as radio text); bridges Matrix replies to Meshtastic replies when a corresponding mapping is found and replies are enabled; relays regular Matrix messages to Meshtastic using configured prefix and truncation rules; and honours detection-sensor forwarding when enabled. Integrates with the plugin system and treats recognized bot commands as non-relayed.
2550
+
2551
+ Side effects:
2552
+ - May enqueue Meshtastic send operations (text or data) via the internal queue.
2553
+ - May read and write persistent message mappings to support reply/reaction bridging.
2554
+ - May call Matrix APIs (e.g., to fetch display names) and connect to Meshtastic.
2555
+ """
2556
+ # DEBUG: Log all Matrix message events to trace reception
2557
+ logger.debug(
2558
+ f"Received Matrix event in room {room.room_id}: {type(event).__name__}"
2559
+ )
2560
+ logger.debug(
2561
+ f"Event details - sender: {event.sender}, timestamp: {event.server_timestamp}"
2562
+ )
2563
+
2564
+ # Importing here to avoid circular imports and to keep logic consistent
2565
+ # Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
2566
+ from mmrelay.message_queue import get_message_queue
2567
+
2568
+ # That logic occurs inside matrix_relay if needed.
2569
+ full_display_name = "Unknown user"
2570
+ message_timestamp = event.server_timestamp
2571
+
2572
+ # We do not relay messages that occurred before the bot started
2573
+ if message_timestamp < bot_start_time:
2574
+ return
2575
+
2576
+ # Do not process messages from the bot itself
2577
+ if event.sender == bot_user_id:
2578
+ return
2579
+
2580
+ # Note: MegolmEvent (encrypted) messages are handled by the `on_decryption_failure`
2581
+ # callback if they fail to decrypt. Successfully decrypted messages are automatically
2582
+ # converted to RoomMessageText/RoomMessageNotice/etc. by matrix-nio and handled normally.
2583
+
2584
+ # Find the room_config that matches this room, if any
2585
+ room_config = None
2586
+ iterable = matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
2587
+ for room_conf in iterable:
2588
+ if isinstance(room_conf, dict) and room_conf.get("id") == room.room_id:
2589
+ room_config = room_conf
2590
+ break
2591
+
2592
+ # Only proceed if the room is supported
2593
+ if not room_config:
2594
+ return
2595
+
2596
+ relates_to = event.source["content"].get("m.relates_to")
2597
+ global config
2598
+
2599
+ # Check if config is available
2600
+ if not config:
2601
+ logger.error("No configuration available for Matrix message processing.")
2602
+
2603
+ is_reaction = False
2604
+ reaction_emoji = None
2605
+ original_matrix_event_id = None
2606
+
2607
+ # Check if config is available
2608
+ if config is None:
2609
+ logger.error("No configuration available. Cannot process Matrix message.")
2610
+ return
2611
+
2612
+ # Get interaction settings
2613
+ interactions = get_interaction_settings(config)
2614
+ storage_enabled = message_storage_enabled(interactions)
2615
+
2616
+ # Check if this is a Matrix ReactionEvent (usually m.reaction)
2617
+ if isinstance(event, ReactionEvent):
2618
+ # This is a reaction event
2619
+ is_reaction = True
2620
+ logger.debug(f"Processing Matrix reaction event: {event.source}")
2621
+ if relates_to and "event_id" in relates_to and "key" in relates_to:
2622
+ # Extract the reaction emoji and the original event it relates to
2623
+ reaction_emoji = relates_to["key"]
2624
+ original_matrix_event_id = relates_to["event_id"]
2625
+ logger.debug(
2626
+ f"Original matrix event ID: {original_matrix_event_id}, Reaction emoji: {reaction_emoji}"
2627
+ )
2628
+
2629
+ # Check if this is a Matrix RoomMessageEmote (m.emote)
2630
+ if isinstance(event, RoomMessageEmote):
2631
+ logger.debug(f"Processing Matrix reaction event: {event.source}")
2632
+ # For RoomMessageEmote, treat as remote reaction if meshtastic_replyId exists
2633
+ is_reaction = True
2634
+ # We need to manually extract the reaction emoji from the body
2635
+ reaction_body = event.source["content"].get("body", "")
2636
+ reaction_match = re.search(r"reacted (.+?) to", reaction_body)
2637
+ reaction_emoji = reaction_match.group(1).strip() if reaction_match else "?"
2638
+
2639
+ text = event.body.strip() if (not is_reaction and hasattr(event, "body")) else ""
2640
+
2641
+ # Some Matrix relays (especially Meshtastic bridges) provide the raw mesh
2642
+ # payload alongside the formatted body. Prefer that when available so we do
2643
+ # not lose content if the formatted text is empty or stripped unexpectedly.
2644
+ mesh_text_override = event.source["content"].get("meshtastic_text")
2645
+ if isinstance(mesh_text_override, str):
2646
+ mesh_text_override = mesh_text_override.strip()
2647
+ if not mesh_text_override:
2648
+ mesh_text_override = None
2649
+ else:
2650
+ mesh_text_override = None
2651
+
2652
+ longname = event.source["content"].get("meshtastic_longname")
2653
+ shortname = event.source["content"].get("meshtastic_shortname", None)
2654
+ meshnet_name = event.source["content"].get("meshtastic_meshnet")
2655
+ meshtastic_replyId = event.source["content"].get("meshtastic_replyId")
2656
+ suppress = event.source["content"].get("mmrelay_suppress")
2657
+
2658
+ # If a message has suppress flag, do not process
2659
+ if suppress:
2660
+ return
2661
+
2662
+ # If this is a reaction and reactions are disabled, do nothing
2663
+ if is_reaction and not interactions["reactions"]:
2664
+ logger.debug(
2665
+ "Reaction event encountered but reactions are disabled. Doing nothing."
2666
+ )
2667
+ return
2668
+
2669
+ local_meshnet_name = config["meshtastic"]["meshnet_name"]
2670
+
2671
+ # Check if this is a Matrix reply (not a reaction)
2672
+ is_reply = False
2673
+ reply_to_event_id = None
2674
+ if not is_reaction and relates_to and "m.in_reply_to" in relates_to:
2675
+ reply_to_event_id = relates_to["m.in_reply_to"].get("event_id")
2676
+ if reply_to_event_id:
2677
+ is_reply = True
2678
+ logger.debug(f"Processing Matrix reply to event: {reply_to_event_id}")
2679
+
2680
+ # If this is a reaction and reactions are enabled, attempt to relay it
2681
+ if is_reaction and interactions["reactions"]:
2682
+ # Check if we need to relay a reaction from a remote meshnet to our local meshnet.
2683
+ # If meshnet_name != local_meshnet_name and meshtastic_replyId is present and this is an emote,
2684
+ # it's a remote reaction that needs to be forwarded as a text message describing the reaction.
2685
+ if (
2686
+ meshnet_name
2687
+ and meshnet_name != local_meshnet_name
2688
+ and meshtastic_replyId
2689
+ and isinstance(event, RoomMessageEmote)
2690
+ ):
2691
+ logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
2692
+
2693
+ short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
2694
+
2695
+ # Format the reaction message for relaying to the local meshnet.
2696
+ # The necessary information is in the m.emote event
2697
+ if not shortname:
2698
+ shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
2699
+
2700
+ meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
2701
+ # Strip out any quoted lines from the text
2702
+ meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
2703
+ meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
2704
+ "\r", " "
2705
+ )
2706
+
2707
+ abbreviated_text = (
2708
+ meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
2709
+ if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
2710
+ else meshtastic_text_db
2711
+ )
2712
+
2713
+ reaction_message = f'{shortname}/{short_meshnet_name} reacted {reaction_emoji} to "{abbreviated_text}"'
2714
+
2715
+ # Relay the remote reaction to the local meshnet.
2716
+ loop = asyncio.get_running_loop()
2717
+ meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
2718
+ if not meshtastic_interface:
2719
+ logger.error(
2720
+ "Failed to connect to Meshtastic for remote reaction relay"
2721
+ )
2722
+ return
2723
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
2724
+
2725
+ meshtastic_channel = room_config["meshtastic_channel"]
2726
+
2727
+ if get_meshtastic_config_value(
2728
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2729
+ ):
2730
+ meshtastic_logger.info(
2731
+ f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
2732
+ )
2733
+ logger.debug(
2734
+ f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
2735
+ )
2736
+ success = queue_message(
2737
+ meshtastic_interface.sendText,
2738
+ text=reaction_message,
2739
+ channelIndex=meshtastic_channel,
2740
+ description=f"Remote reaction from {meshnet_name}",
2741
+ )
2742
+
2743
+ if success:
2744
+ logger.debug(
2745
+ f"Queued remote reaction to Meshtastic: {reaction_message}"
2746
+ )
2747
+ else:
2748
+ logger.error("Failed to relay remote reaction to Meshtastic")
2749
+ return
2750
+ # We've relayed the remote reaction to our local mesh, so we're done.
2751
+ return
2752
+
2753
+ # If original_matrix_event_id is set, this is a reaction to some other matrix event
2754
+ if original_matrix_event_id:
2755
+ orig = get_message_map_by_matrix_event_id(original_matrix_event_id)
2756
+ if not orig:
2757
+ # If we don't find the original message in the DB, we suspect it's a reaction-to-reaction scenario
2758
+ logger.debug(
2759
+ "Original message for reaction not found in DB. Possibly a reaction-to-reaction scenario. Not forwarding."
2760
+ )
2761
+ return
2762
+
2763
+ # orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
2764
+ meshtastic_id, matrix_room_id, meshtastic_text_db, meshtastic_meshnet_db = (
2765
+ orig
2766
+ )
2767
+ # Get room-specific display name if available, fallback to global display name
2768
+ room_display_name = room.user_name(event.sender)
2769
+ if room_display_name:
2770
+ full_display_name = room_display_name
2771
+ else:
2772
+ # Fallback to global display name if room-specific name is not available
2773
+ display_name_response = await matrix_client.get_displayname(
2774
+ event.sender
2775
+ )
2776
+ full_display_name = display_name_response.displayname or event.sender
2777
+
2778
+ # If not from a remote meshnet, proceed as normal to relay back to the originating meshnet
2779
+ prefix = get_meshtastic_prefix(config, full_display_name)
2780
+
2781
+ # Remove quoted lines so we don't bring in the original '>' lines from replies
2782
+ meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
2783
+ meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
2784
+ "\r", " "
2785
+ )
2786
+
2787
+ abbreviated_text = (
2788
+ meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
2789
+ if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
2790
+ else meshtastic_text_db
2791
+ )
2792
+
2793
+ # Always use our local meshnet_name for outgoing events
2794
+ reaction_message = (
2795
+ f'{prefix}reacted {reaction_emoji} to "{abbreviated_text}"'
2796
+ )
2797
+ loop = asyncio.get_running_loop()
2798
+ meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
2799
+ if not meshtastic_interface:
2800
+ logger.error("Failed to connect to Meshtastic for local reaction relay")
2801
+ return
2802
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
2803
+
2804
+ meshtastic_channel = room_config["meshtastic_channel"]
2805
+
2806
+ if get_meshtastic_config_value(
2807
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2808
+ ):
2809
+ meshtastic_logger.info(
2810
+ f"Relaying reaction from {full_display_name} to radio broadcast"
2811
+ )
2812
+ logger.debug(
2813
+ f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
2814
+ )
2815
+ success = queue_message(
2816
+ meshtastic_interface.sendText,
2817
+ text=reaction_message,
2818
+ channelIndex=meshtastic_channel,
2819
+ description=f"Local reaction from {full_display_name}",
2820
+ )
2821
+
2822
+ if success:
2823
+ logger.debug(
2824
+ f"Queued local reaction to Meshtastic: {reaction_message}"
2825
+ )
2826
+ else:
2827
+ logger.error("Failed to relay local reaction to Meshtastic")
2828
+ return
2829
+ return
2830
+
2831
+ # Handle Matrix replies to Meshtastic messages (only if replies are enabled)
2832
+ if is_reply and reply_to_event_id and interactions["replies"]:
2833
+ reply_handled = await handle_matrix_reply(
2834
+ room,
2835
+ event,
2836
+ reply_to_event_id,
2837
+ text,
2838
+ room_config,
2839
+ storage_enabled,
2840
+ local_meshnet_name,
2841
+ config,
2842
+ mesh_text_override=mesh_text_override,
2843
+ longname=longname,
2844
+ shortname=shortname,
2845
+ meshnet_name=meshnet_name,
2846
+ )
2847
+ if reply_handled:
2848
+ return
2849
+
2850
+ # For Matrix->Mesh messages from a remote meshnet, rewrite the message format
2851
+ if longname and meshnet_name:
2852
+ # Always include the meshnet_name in the full display name.
2853
+ full_display_name = f"{longname}/{meshnet_name}"
2854
+
2855
+ if meshnet_name != local_meshnet_name:
2856
+ # A message from a remote meshnet relayed into Matrix, now going back out
2857
+ logger.info(f"Processing message from remote meshnet: {meshnet_name}")
2858
+ short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
2859
+ # If shortname is not available, derive it from the longname
2860
+ if shortname is None:
2861
+ shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
2862
+ if mesh_text_override:
2863
+ text = mesh_text_override
2864
+ # Remove the original prefix to avoid double-tagging
2865
+ # Get the prefix that would have been used for this message
2866
+ original_prefix = get_matrix_prefix(
2867
+ config, longname, shortname, meshnet_name
2868
+ )
2869
+ if original_prefix and text.startswith(original_prefix):
2870
+ text = text[len(original_prefix) :]
2871
+ logger.debug(
2872
+ f"Removed original prefix '{original_prefix}' from remote meshnet message"
2873
+ )
2874
+ if not text and mesh_text_override:
2875
+ text = mesh_text_override
2876
+ text = truncate_message(text)
2877
+ # Use the configured prefix format for remote meshnet messages
2878
+ prefix = get_matrix_prefix(config, longname, shortname, short_meshnet_name)
2879
+ full_message = f"{prefix}{text}"
2880
+ if not text:
2881
+ logger.warning(
2882
+ "Remote meshnet message from %s had empty text after formatting; skipping relay",
2883
+ meshnet_name,
2884
+ )
2885
+ return
2886
+ else:
2887
+ # If this message is from our local meshnet (loopback), we ignore it
2888
+ return
2889
+ else:
2890
+ # Normal Matrix message from a Matrix user
2891
+ # Get room-specific display name if available, fallback to global display name
2892
+ room_display_name = room.user_name(event.sender)
2893
+ if room_display_name:
2894
+ full_display_name = room_display_name
2895
+ else:
2896
+ # Fallback to global display name if room-specific name is not available
2897
+ display_name_response = await matrix_client.get_displayname(event.sender)
2898
+ full_display_name = display_name_response.displayname or event.sender
2899
+ prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
2900
+ logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
2901
+ full_message = f"{prefix}{text}"
2902
+ full_message = truncate_message(full_message)
2903
+
2904
+ # Plugin functionality
2905
+ from mmrelay.plugin_loader import load_plugins
2906
+
2907
+ plugins = load_plugins()
2908
+
2909
+ found_matching_plugin = False
2910
+ for plugin in plugins:
2911
+ if not found_matching_plugin:
2912
+ try:
2913
+ found_matching_plugin = await plugin.handle_room_message(
2914
+ room, event, full_message
2915
+ )
2916
+ if found_matching_plugin:
2917
+ logger.info(
2918
+ f"Processed command with plugin: {plugin.plugin_name} from {event.sender}"
2919
+ )
2920
+ except Exception as e:
2921
+ logger.error(
2922
+ f"Error processing message with plugin {plugin.plugin_name}: {e}"
2923
+ )
2924
+
2925
+ # Check if the message is a command directed at the bot
2926
+ is_command = False
2927
+ for plugin in plugins:
2928
+ for command in plugin.get_matrix_commands():
2929
+ if bot_command(command, event):
2930
+ is_command = True
2931
+ break
2932
+ if is_command:
2933
+ break
2934
+
2935
+ # If this is a command, we do not send it to the mesh
2936
+ if is_command:
2937
+ logger.debug("Message is a command, not sending to mesh")
2938
+ return
2939
+
2940
+ # Connect to Meshtastic
2941
+ loop = asyncio.get_running_loop()
2942
+ meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
2943
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
2944
+
2945
+ if not meshtastic_interface:
2946
+ logger.error("Failed to connect to Meshtastic. Cannot relay message.")
2947
+ return
2948
+
2949
+ meshtastic_channel = room_config["meshtastic_channel"]
2950
+
2951
+ # If message is from Matrix and broadcast_enabled is True, relay to Meshtastic
2952
+ # Note: If relay_reactions is False, we won't store message_map, but we can still relay.
2953
+ # The lack of message_map storage just means no reaction bridging will occur.
2954
+ if not found_matching_plugin:
2955
+ if get_meshtastic_config_value(
2956
+ config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
2957
+ ):
2958
+ portnum = event.source["content"].get("meshtastic_portnum")
2959
+ if portnum == DETECTION_SENSOR_APP:
2960
+ # If detection_sensor is enabled, forward this data as detection sensor data
2961
+ if get_meshtastic_config_value(
2962
+ config, "detection_sensor", DEFAULT_DETECTION_SENSOR
2963
+ ):
2964
+ # Import meshtastic protobuf only when needed to delay logger creation
2965
+ import meshtastic.protobuf.portnums_pb2
2966
+
2967
+ success = queue_message(
2968
+ meshtastic_interface.sendData,
2969
+ data=full_message.encode("utf-8"),
2970
+ channelIndex=meshtastic_channel,
2971
+ portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
2972
+ description=f"Detection sensor data from {full_display_name}",
2973
+ )
2974
+
2975
+ if success:
2976
+ # Get queue size to determine logging approach
2977
+ queue_size = get_message_queue().get_queue_size()
2978
+
2979
+ if queue_size > 1:
2980
+ meshtastic_logger.info(
2981
+ f"Relaying detection sensor data from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
2982
+ )
2983
+ else:
2984
+ meshtastic_logger.info(
2985
+ f"Relaying detection sensor data from {full_display_name} to radio broadcast"
2986
+ )
2987
+ # Note: Detection sensor messages are not stored in message_map because they are never replied to
2988
+ # Only TEXT_MESSAGE_APP messages need to be stored for reaction handling
2989
+ else:
2990
+ meshtastic_logger.error(
2991
+ "Failed to relay detection sensor data to Meshtastic"
2992
+ )
2993
+ return
2994
+ else:
2995
+ meshtastic_logger.debug(
2996
+ f"Detection sensor packet received from {full_display_name}, but detection sensor processing is disabled."
2997
+ )
2998
+ else:
2999
+ # Regular text message - logging will be handled by queue success handler
3000
+ pass
3001
+
3002
+ # Create mapping info if storage is enabled
3003
+ mapping_info = None
3004
+ if storage_enabled:
3005
+ # Check database config for message map settings (preferred format)
3006
+ msgs_to_keep = _get_msgs_to_keep_config()
3007
+
3008
+ mapping_info = _create_mapping_info(
3009
+ event.event_id,
3010
+ room.room_id,
3011
+ text,
3012
+ local_meshnet_name,
3013
+ msgs_to_keep,
3014
+ )
3015
+
3016
+ success = queue_message(
3017
+ meshtastic_interface.sendText,
3018
+ text=full_message,
3019
+ channelIndex=meshtastic_channel,
3020
+ description=f"Message from {full_display_name}",
3021
+ mapping_info=mapping_info,
3022
+ )
3023
+
3024
+ if success:
3025
+ # Get queue size to determine logging approach
3026
+ queue_size = get_message_queue().get_queue_size()
3027
+
3028
+ if queue_size > 1:
3029
+ meshtastic_logger.info(
3030
+ f"Relaying message from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
3031
+ )
3032
+ else:
3033
+ meshtastic_logger.info(
3034
+ f"Relaying message from {full_display_name} to radio broadcast"
3035
+ )
3036
+ else:
3037
+ meshtastic_logger.error("Failed to relay message to Meshtastic")
3038
+ return
3039
+ # Message mapping is now handled automatically by the queue system
3040
+ else:
3041
+ logger.debug(
3042
+ f"broadcast_enabled is False - not relaying message from {full_display_name} to Meshtastic"
3043
+ )
3044
+
3045
+
3046
+ async def upload_image(
3047
+ client: AsyncClient, image: Image.Image, filename: str
3048
+ ) -> UploadResponse:
3049
+ """
3050
+ Uploads an image to Matrix and returns the UploadResponse containing the content URI.
3051
+ """
3052
+ buffer = io.BytesIO()
3053
+ image.save(buffer, format="PNG")
3054
+ image_data = buffer.getvalue()
3055
+
3056
+ response, maybe_keys = await client.upload(
3057
+ io.BytesIO(image_data),
3058
+ content_type="image/png",
3059
+ filename=filename,
3060
+ filesize=len(image_data),
3061
+ )
3062
+
3063
+ return response
3064
+
3065
+
3066
+ async def send_room_image(
3067
+ client: AsyncClient, room_id: str, upload_response: UploadResponse
3068
+ ):
3069
+ """
3070
+ Sends an already uploaded image to the specified room.
3071
+ """
3072
+ await client.room_send(
3073
+ room_id=room_id,
3074
+ message_type="m.room.message",
3075
+ content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""},
3076
+ )
3077
+
3078
+
3079
+ async def on_room_member(room: MatrixRoom, event: RoomMemberEvent) -> None:
3080
+ """
3081
+ Callback to handle room member events, specifically tracking room-specific display name changes.
3082
+ This ensures we detect when users update their display names in specific rooms.
3083
+
3084
+ Note: This callback doesn't need to do any explicit processing since matrix-nio
3085
+ automatically updates the room state and room.user_name() will return the
3086
+ updated room-specific display name immediately after this event.
3087
+ """
3088
+ # The callback is registered to ensure matrix-nio processes the event,
3089
+ # but no explicit action is needed since room.user_name() automatically
3090
+ # handles room-specific display names after the room state is updated.
3091
+ pass