mmrelay 1.2.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

mmrelay/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
  Meshtastic Matrix Relay - Bridge between Meshtastic mesh networks and Matrix chat rooms.
3
3
  """
4
4
 
5
- __version__ = "1.2.0"
5
+ __version__ = "1.2.1"
mmrelay/cli.py CHANGED
@@ -7,6 +7,7 @@ import importlib.resources
7
7
  import os
8
8
  import shutil
9
9
  import sys
10
+ from collections.abc import Mapping
10
11
 
11
12
  # Import version from package
12
13
  from mmrelay import __version__
@@ -141,11 +142,24 @@ def parse_arguments():
141
142
  auth_subparsers = auth_parser.add_subparsers(
142
143
  dest="auth_command", help="Auth commands"
143
144
  )
144
- auth_subparsers.add_parser(
145
+ login_parser = auth_subparsers.add_parser(
145
146
  "login",
146
147
  help="Authenticate with Matrix",
147
148
  description="Set up Matrix authentication for E2EE support",
148
149
  )
150
+ login_parser.add_argument(
151
+ "--homeserver",
152
+ help="Matrix homeserver URL (e.g., https://matrix.org). If provided, --username and --password are also required.",
153
+ )
154
+ login_parser.add_argument(
155
+ "--username",
156
+ help="Matrix username (with or without @ and :server). If provided, --homeserver and --password are also required.",
157
+ )
158
+ login_parser.add_argument(
159
+ "--password",
160
+ metavar="PWD",
161
+ help="Matrix password (can be empty). If provided, --homeserver and --username are also required. For security, prefer interactive mode.",
162
+ )
149
163
 
150
164
  auth_subparsers.add_parser(
151
165
  "status",
@@ -189,9 +203,10 @@ def parse_arguments():
189
203
 
190
204
  # Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
191
205
  args, unknown = parser.parse_known_args()
192
- # If there are unknown arguments and we're not in a test environment, warn about them
193
- if unknown and not any("pytest" in arg or "test" in arg for arg in sys.argv):
194
- print(f"Warning: Unknown arguments ignored: {unknown}")
206
+ # If there are unknown arguments and we're not in a test invocation, warn about them
207
+ # Heuristic: suppress warning when pytest appears in argv (unit tests may pass extra args)
208
+ if unknown and not any("pytest" in arg or "py.test" in arg for arg in sys.argv):
209
+ print(f"Warning: Unknown arguments ignored: {unknown}", file=sys.stderr)
195
210
 
196
211
  return args
197
212
 
@@ -246,35 +261,26 @@ def _validate_e2ee_dependencies():
246
261
 
247
262
  def _validate_credentials_json(config_path):
248
263
  """
249
- Validate that a credentials.json file exists next to the given config and contains the required Matrix authentication fields.
264
+ Validate that a credentials.json file exists (adjacent to config_path or in the base directory) and contains the required Matrix session fields.
250
265
 
251
- Searches for credentials.json in the same directory as config_path, then falls back to the application's base directory. If found, the file is parsed as JSON and must include non-empty values for: "homeserver", "access_token", "user_id", and "device_id".
266
+ Checks for a credentials.json via _find_credentials_json_path(config_path). If found, the file is parsed as JSON and must include non-empty string values for the keys "homeserver", "access_token", "user_id", and "device_id". On validation failure the function prints a brief error and guidance to run the auth login flow.
252
267
 
253
268
  Parameters:
254
269
  config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
255
270
 
256
271
  Returns:
257
- bool: True if a valid credentials.json was found and contains all required fields; False otherwise. When invalid or missing fields are detected the function prints a short error and guidance to run the auth login flow.
272
+ bool: True if a credentials.json was found and contains all required non-empty fields; False otherwise.
258
273
  """
259
274
  try:
260
275
  import json
261
276
 
262
- # Look for credentials.json in the same directory as the config file
263
- config_dir = os.path.dirname(config_path)
264
- credentials_path = os.path.join(config_dir, "credentials.json")
265
-
266
- if not os.path.exists(credentials_path):
267
- # Also try the standard location
268
- from mmrelay.config import get_base_dir
269
-
270
- standard_credentials_path = os.path.join(get_base_dir(), "credentials.json")
271
- if os.path.exists(standard_credentials_path):
272
- credentials_path = standard_credentials_path
273
- else:
274
- return False
277
+ # Look for credentials.json using helper function
278
+ credentials_path = _find_credentials_json_path(config_path)
279
+ if not credentials_path:
280
+ return False
275
281
 
276
282
  # Load and validate credentials
277
- with open(credentials_path, "r") as f:
283
+ with open(credentials_path, "r", encoding="utf-8") as f:
278
284
  credentials = json.load(f)
279
285
 
280
286
  # Check for required fields
@@ -282,7 +288,7 @@ def _validate_credentials_json(config_path):
282
288
  missing_fields = [
283
289
  field
284
290
  for field in required_fields
285
- if field not in credentials or not credentials[field]
291
+ if not _is_valid_non_empty_string((credentials or {}).get(field))
286
292
  ]
287
293
 
288
294
  if missing_fields:
@@ -298,6 +304,52 @@ def _validate_credentials_json(config_path):
298
304
  return False
299
305
 
300
306
 
307
+ def _is_valid_non_empty_string(value) -> bool:
308
+ """
309
+ Return True if value is a string containing non-whitespace characters.
310
+
311
+ Checks that the input is an instance of `str` and that stripping whitespace
312
+ does not produce an empty string.
313
+
314
+ Returns:
315
+ bool: True when value is a non-empty, non-whitespace-only string; otherwise False.
316
+ """
317
+ return isinstance(value, str) and value.strip() != ""
318
+
319
+
320
+ def _has_valid_password_auth(matrix_section):
321
+ """
322
+ Return True if the given Matrix config section contains valid password-based authentication settings.
323
+
324
+ The function expects matrix_section to be a dict-like mapping from configuration keys to values.
325
+ It validates that:
326
+ - `homeserver` and `bot_user_id` are present and are non-empty strings (after trimming),
327
+ - `password` is present and is a string (it may be an empty string, which is accepted).
328
+
329
+ If matrix_section is not a dict, the function returns False.
330
+
331
+ Parameters:
332
+ matrix_section: dict-like Matrix configuration section (may be the parsed "matrix" config).
333
+
334
+ Returns:
335
+ bool: True when password-based authentication is correctly configured as described above; otherwise False.
336
+ """
337
+ if not isinstance(matrix_section, Mapping):
338
+ return False
339
+
340
+ pwd = matrix_section.get("password")
341
+ homeserver = matrix_section.get(CONFIG_KEY_HOMESERVER)
342
+ bot_user_id = matrix_section.get(CONFIG_KEY_BOT_USER_ID)
343
+
344
+ # Allow empty password strings (some environments legitimately use empty passwords).
345
+ # Homeserver and bot_user_id must still be valid non-empty strings.
346
+ return (
347
+ isinstance(pwd, str)
348
+ and _is_valid_non_empty_string(homeserver)
349
+ and _is_valid_non_empty_string(bot_user_id)
350
+ )
351
+
352
+
301
353
  def _validate_matrix_authentication(config_path, matrix_section):
302
354
  """
303
355
  Determine whether Matrix authentication is configured and usable.
@@ -323,7 +375,10 @@ def _validate_matrix_authentication(config_path, matrix_section):
323
375
  and whether E2EE support is available.
324
376
  """
325
377
  has_valid_credentials = _validate_credentials_json(config_path)
326
- has_access_token = matrix_section and "access_token" in matrix_section
378
+ token = (matrix_section or {}).get(CONFIG_KEY_ACCESS_TOKEN)
379
+ has_access_token = _is_valid_non_empty_string(token)
380
+
381
+ has_password = _has_valid_password_auth(matrix_section)
327
382
 
328
383
  if has_valid_credentials:
329
384
  print("✅ Using credentials.json for Matrix authentication")
@@ -331,8 +386,16 @@ def _validate_matrix_authentication(config_path, matrix_section):
331
386
  print(" E2EE support available (if enabled)")
332
387
  return True
333
388
 
389
+ elif has_password:
390
+ print(
391
+ "✅ Using password in config for initial authentication (credentials.json will be created on first run)"
392
+ )
393
+ print(f" {msg_for_e2ee_support()}")
394
+ return True
334
395
  elif has_access_token:
335
- print("✅ Using access_token for Matrix authentication")
396
+ print(
397
+ "✅ Using access_token for Matrix authentication (deprecated — consider 'mmrelay auth login' to create credentials.json)"
398
+ )
336
399
  print(f" {msg_for_e2ee_support()}")
337
400
  return True
338
401
 
@@ -388,7 +451,7 @@ def _validate_e2ee_config(config, matrix_section, config_path):
388
451
  )
389
452
  if store_path:
390
453
  expanded_path = os.path.expanduser(store_path)
391
- if not os.path.exists(os.path.dirname(expanded_path)):
454
+ if not os.path.exists(expanded_path):
392
455
  print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
393
456
 
394
457
  print("✅ E2EE configuration is valid")
@@ -470,19 +533,9 @@ def _analyze_e2ee_setup(config, config_path):
470
533
  "Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
471
534
  )
472
535
 
473
- # Check credentials (same logic as _validate_credentials_json)
474
- config_dir = os.path.dirname(config_path)
475
- credentials_path = os.path.join(config_dir, "credentials.json")
476
-
477
- if not os.path.exists(credentials_path):
478
- # Also try the standard location
479
- from mmrelay.config import get_base_dir
480
-
481
- standard_credentials_path = os.path.join(get_base_dir(), "credentials.json")
482
- if os.path.exists(standard_credentials_path):
483
- credentials_path = standard_credentials_path
484
-
485
- analysis["credentials_available"] = os.path.exists(credentials_path)
536
+ # Check credentials file existence
537
+ credentials_path = _find_credentials_json_path(config_path)
538
+ analysis["credentials_available"] = bool(credentials_path)
486
539
 
487
540
  if not analysis["credentials_available"]:
488
541
  analysis["recommendations"].append(
@@ -506,54 +559,89 @@ def _analyze_e2ee_setup(config, config_path):
506
559
  return analysis
507
560
 
508
561
 
562
+ def _find_credentials_json_path(config_path: str | None) -> str | None:
563
+ """
564
+ Return the filesystem path to a credentials.json file if one can be found, otherwise None.
565
+
566
+ Search order:
567
+ 1. A credentials.json file located in the same directory as the provided config_path.
568
+ 2. A credentials.json file in the application's base directory (get_base_dir()).
569
+
570
+ Parameters:
571
+ config_path (str | None): Path to the configuration file used to derive the adjacent credentials.json location.
572
+
573
+ Returns:
574
+ str | None: Absolute path to the discovered credentials.json, or None if no file is found.
575
+ """
576
+ if not config_path:
577
+ from mmrelay.config import get_base_dir
578
+
579
+ standard = os.path.join(get_base_dir(), "credentials.json")
580
+ return standard if os.path.exists(standard) else None
581
+
582
+ config_dir = os.path.dirname(config_path)
583
+ candidate = os.path.join(config_dir, "credentials.json")
584
+ if os.path.exists(candidate):
585
+ return candidate
586
+ from mmrelay.config import get_base_dir
587
+
588
+ standard = os.path.join(get_base_dir(), "credentials.json")
589
+ return standard if os.path.exists(standard) else None
590
+
591
+
509
592
  def _print_unified_e2ee_analysis(e2ee_status):
510
593
  """
511
- Print a concise, user-facing analysis of E2EE readiness from a centralized status object.
594
+ Print a concise, user-facing analysis of E2EE readiness.
512
595
 
513
- This formats and prints the platform support, dependency availability, configuration enabled state,
514
- authentication (credentials.json) presence, and the overall status. If the overall status is not
515
- "ready", prints actionable fix instructions obtained from mmrelay.e2ee_utils.get_e2ee_fix_instructions.
596
+ Given a status dictionary produced by the E2EE analysis routines, prints platform support,
597
+ dependency availability, whether E2EE is enabled in configuration, whether credentials.json
598
+ is available, and the overall status. If the overall status is not "ready", prints actionable
599
+ fix instructions.
516
600
 
517
601
  Parameters:
518
- e2ee_status (dict): Status dictionary as returned by get_e2ee_status(config, config_path).
519
- Expected keys:
520
- - platform_supported (bool)
521
- - dependencies_installed (bool)
522
- - enabled (bool)
523
- - credentials_available (bool)
524
- - overall_status (str)
602
+ e2ee_status (dict): Status dictionary with (at least) the following keys:
603
+ - platform_supported (bool): whether the current OS/platform supports E2EE.
604
+ - dependencies_installed or dependencies_available (bool): whether required E2EE
605
+ Python packages and runtime dependencies are present.
606
+ - enabled or config_enabled (bool): whether E2EE is enabled in the configuration.
607
+ - credentials_available (bool): whether a usable credentials.json is present.
608
+ - overall_status (str): high-level status ("ready", "disabled", "incomplete", etc.).
525
609
  """
526
610
  print("\n🔐 E2EE Configuration Analysis:")
527
611
 
528
612
  # Platform support
529
- if e2ee_status["platform_supported"]:
613
+ if e2ee_status.get("platform_supported", True):
530
614
  print("✅ Platform: E2EE supported")
531
615
  else:
532
616
  print("❌ Platform: E2EE not supported on Windows")
533
617
 
534
618
  # Dependencies
535
- if e2ee_status["dependencies_installed"]:
619
+ if e2ee_status.get(
620
+ "dependencies_installed", e2ee_status.get("dependencies_available", False)
621
+ ):
536
622
  print("✅ Dependencies: E2EE dependencies installed")
537
623
  else:
538
624
  print("❌ Dependencies: E2EE dependencies not fully installed")
539
625
 
540
626
  # Configuration
541
- if e2ee_status["enabled"]:
627
+ if e2ee_status.get("enabled", e2ee_status.get("config_enabled", False)):
542
628
  print("✅ Configuration: E2EE enabled")
543
629
  else:
544
630
  print("❌ Configuration: E2EE disabled")
545
631
 
546
632
  # Authentication
547
- if e2ee_status["credentials_available"]:
633
+ if e2ee_status.get("credentials_available", False):
548
634
  print("✅ Authentication: credentials.json found")
549
635
  else:
550
636
  print("❌ Authentication: credentials.json not found")
551
637
 
552
638
  # Overall status
553
- print(f"\n📊 Overall Status: {e2ee_status['overall_status'].upper()}")
639
+ print(
640
+ f"\n📊 Overall Status: {e2ee_status.get('overall_status', 'unknown').upper()}"
641
+ )
554
642
 
555
643
  # Show fix instructions if needed
556
- if e2ee_status["overall_status"] != "ready":
644
+ if e2ee_status.get("overall_status") != "ready":
557
645
  from mmrelay.e2ee_utils import get_e2ee_fix_instructions
558
646
 
559
647
  instructions = get_e2ee_fix_instructions(e2ee_status)
@@ -686,13 +774,13 @@ def check_config(args=None):
686
774
  - Locates the first existing config file from get_config_paths(args) (parses CLI args if args is None).
687
775
  - Verifies YAML syntax and reports syntax errors or style warnings.
688
776
  - Ensures the config is non-empty.
689
- - Validates Matrix authentication: accepts credentials supplied via credentials.json or requires a matrix section with homeserver, access_token, and bot_user_id when credentials.json is absent.
777
+ - Validates Matrix authentication: accepts credentials supplied via credentials.json or requires a matrix section with homeserver and bot_user_id plus either access_token or password when credentials.json is absent.
690
778
  - Validates end-to-end-encryption (E2EE) configuration and dependencies.
691
779
  - Ensures matrix_rooms exists, is a non-empty list, and each room is a dict containing an id.
692
780
  - Validates the meshtastic section: requires connection_type and the connection-specific fields (serial_port for serial, host for tcp/network, ble_address for ble). Warns about deprecated connection types.
693
781
  - Validates optional meshtastic fields and types (broadcast_enabled, detection_sensor, message_delay >= 2.0, meshnet_name) and reports missing optional settings as guidance.
694
782
  - Warns if a deprecated db section is present.
695
- - Prints a short environment summary on success.
783
+ - Prints a unified E2EE analysis summary on success.
696
784
 
697
785
  Side effects:
698
786
  - Prints errors, warnings, and status messages to stdout.
@@ -748,6 +836,9 @@ def check_config(args=None):
748
836
  # Create empty matrix section if missing - no fields required
749
837
  config[CONFIG_SECTION_MATRIX] = {}
750
838
  matrix_section = config[CONFIG_SECTION_MATRIX]
839
+ if not isinstance(matrix_section, dict):
840
+ print("Error: 'matrix' section must be a mapping (YAML object)")
841
+ return False
751
842
  required_matrix_fields = (
752
843
  []
753
844
  ) # No fields required from config when using credentials.json
@@ -756,22 +847,37 @@ def check_config(args=None):
756
847
  if CONFIG_SECTION_MATRIX not in config:
757
848
  print("Error: Missing 'matrix' section in config")
758
849
  print(
759
- " Either add matrix section with access_token and bot_user_id,"
850
+ " Either add matrix section with access_token or password and bot_user_id,"
760
851
  )
761
852
  print(f" {msg_or_run_auth_login()}")
762
853
  return False
763
854
 
764
855
  matrix_section = config[CONFIG_SECTION_MATRIX]
856
+ if not isinstance(matrix_section, dict):
857
+ print("Error: 'matrix' section must be a mapping (YAML object)")
858
+ return False
859
+
765
860
  required_matrix_fields = [
766
861
  CONFIG_KEY_HOMESERVER,
767
- CONFIG_KEY_ACCESS_TOKEN,
768
862
  CONFIG_KEY_BOT_USER_ID,
769
863
  ]
864
+ token = matrix_section.get(CONFIG_KEY_ACCESS_TOKEN)
865
+ pwd = matrix_section.get("password")
866
+ has_token = _is_valid_non_empty_string(token)
867
+ # Allow explicitly empty password strings; require the value to be a string
868
+ # (reject unquoted numeric types)
869
+ has_password = isinstance(pwd, str)
870
+ if not (has_token or has_password):
871
+ print(
872
+ "Error: Missing authentication in 'matrix' section: provide 'access_token' or 'password'"
873
+ )
874
+ print(f" {msg_or_run_auth_login()}")
875
+ return False
770
876
 
771
877
  missing_matrix_fields = [
772
878
  field
773
879
  for field in required_matrix_fields
774
- if field not in matrix_section
880
+ if not _is_valid_non_empty_string(matrix_section.get(field))
775
881
  ]
776
882
 
777
883
  if missing_matrix_fields:
@@ -1096,24 +1202,78 @@ def handle_auth_command(args):
1096
1202
 
1097
1203
  def handle_auth_login(args):
1098
1204
  """
1099
- Run the interactive Matrix bot login flow and return a CLI-style exit code.
1205
+ Run the Matrix bot login flow and return a CLI-style exit code.
1100
1206
 
1101
- Runs the login_matrix_bot coroutine to perform authentication for the Matrix/E2EE bot and prints a short header. Returns 0 on successful authentication; returns 1 if the login fails, is cancelled by the user (KeyboardInterrupt), or an unexpected error occurs.
1207
+ Performs Matrix bot authentication either interactively (prompts the user) or non-interactively
1208
+ when all three parameters (--homeserver, --username, --password) are provided on the command line.
1209
+ For non-interactive mode, --homeserver and --username must be non-empty strings; --password may be
1210
+ an empty string (some flows will prompt). Supplying some but not all of the three parameters
1211
+ is treated as an error and the function exits with a non-zero status.
1212
+
1213
+ Returns:
1214
+ int: 0 on successful authentication, 1 on failure, cancellation (KeyboardInterrupt), or unexpected errors.
1102
1215
 
1103
1216
  Parameters:
1104
- args: Parsed command-line arguments (not used by this handler).
1217
+ args: Parsed CLI namespace; may contain attributes `homeserver`, `username`, and `password`.
1105
1218
  """
1106
1219
  import asyncio
1107
1220
 
1108
1221
  from mmrelay.matrix_utils import login_matrix_bot
1109
1222
 
1110
- # Show header
1111
- print("Matrix Bot Authentication for E2EE")
1112
- print("===================================")
1223
+ # Extract arguments
1224
+ homeserver = getattr(args, "homeserver", None)
1225
+ username = getattr(args, "username", None)
1226
+ password = getattr(args, "password", None)
1227
+
1228
+ # Count provided parameters (empty strings count as provided)
1229
+ provided_params = [p for p in [homeserver, username, password] if p is not None]
1230
+
1231
+ # Determine mode based on parameters provided
1232
+ if len(provided_params) == 3:
1233
+ # All parameters provided - validate required non-empty fields
1234
+ if not _is_valid_non_empty_string(homeserver) or not _is_valid_non_empty_string(
1235
+ username
1236
+ ):
1237
+ print(
1238
+ "❌ Error: --homeserver and --username must be non-empty for non-interactive login."
1239
+ )
1240
+ return 1
1241
+ # Password may be empty (flows may prompt)
1242
+ elif len(provided_params) > 0:
1243
+ # Some but not all parameters provided - show error
1244
+ missing_params = []
1245
+ if homeserver is None:
1246
+ missing_params.append("--homeserver")
1247
+ if username is None:
1248
+ missing_params.append("--username")
1249
+ if password is None:
1250
+ missing_params.append("--password")
1251
+
1252
+ error_message = f"""❌ Error: All authentication parameters are required when using command-line options.
1253
+ Missing: {', '.join(missing_params)}
1254
+
1255
+ 💡 Options:
1256
+ • For secure interactive authentication: mmrelay auth login
1257
+ • For automated authentication: provide all three parameters
1258
+
1259
+ ⚠️ Security Note: Command-line passwords may be visible in process lists and shell history.
1260
+ Interactive mode is recommended for manual use."""
1261
+ print(error_message)
1262
+ return 1
1263
+ else:
1264
+ # No parameters provided - run in interactive mode
1265
+ print("Matrix Bot Authentication for E2EE")
1266
+ print("===================================")
1113
1267
 
1114
1268
  try:
1115
- # For now, use the existing login function
1116
- result = asyncio.run(login_matrix_bot())
1269
+ result = asyncio.run(
1270
+ login_matrix_bot(
1271
+ homeserver=homeserver,
1272
+ username=username,
1273
+ password=password,
1274
+ logout_others=False,
1275
+ )
1276
+ )
1117
1277
  return 0 if result else 1
1118
1278
  except KeyboardInterrupt:
1119
1279
  print("\nAuthentication cancelled by user.")
@@ -1125,38 +1285,66 @@ def handle_auth_login(args):
1125
1285
 
1126
1286
  def handle_auth_status(args):
1127
1287
  """
1128
- Show Matrix authentication status by locating and reading a credentials.json file.
1288
+ Print the Matrix authentication status by locating and reading a credentials.json file.
1129
1289
 
1130
- Searches candidate config directories derived from the provided parsed-arguments namespace for a credentials.json file. If found and readable, prints the file path and the homeserver, user_id, and device_id values. If the file is unreadable or not found, prints guidance to run the authentication flow.
1290
+ Searches for credentials.json next to each discovered config file (in preference order),
1291
+ then falls back to the application's base directory. If a readable credentials.json is
1292
+ found, prints its path and the homeserver, user_id, and device_id values.
1131
1293
 
1132
1294
  Parameters:
1133
- args (argparse.Namespace): Parsed CLI arguments used to determine the list of config paths to search.
1295
+ args: argparse.Namespace
1296
+ Parsed CLI arguments (used to locate config file paths).
1134
1297
 
1135
1298
  Returns:
1136
- int: Exit code — 0 if a readable credentials.json was found, 1 otherwise.
1299
+ int: Exit code — 0 if a valid credentials.json was found and read, 1 otherwise.
1300
+
1301
+ Side effects:
1302
+ Writes human-readable status messages to stdout.
1137
1303
  """
1138
1304
  import json
1139
- import os
1140
1305
 
1141
- from mmrelay.config import get_config_paths
1306
+ from mmrelay.config import get_base_dir, get_config_paths
1142
1307
 
1143
1308
  print("Matrix Authentication Status")
1144
1309
  print("============================")
1145
1310
 
1146
- # Check for credentials.json
1147
1311
  config_paths = get_config_paths(args)
1148
- for config_path in config_paths:
1149
- config_dir = os.path.dirname(config_path)
1150
- credentials_path = os.path.join(config_dir, "credentials.json")
1312
+
1313
+ # Developer note: Build a de-duplicated sequence of candidate locations,
1314
+ # preserving preference order: each config-adjacent credentials.json first,
1315
+ # then the standard base-dir fallback.
1316
+ seen = set()
1317
+ candidate_paths = []
1318
+ for p in (
1319
+ os.path.join(os.path.dirname(cp), "credentials.json") for cp in config_paths
1320
+ ):
1321
+ if p not in seen:
1322
+ candidate_paths.append(p)
1323
+ seen.add(p)
1324
+ base_candidate = os.path.join(get_base_dir(), "credentials.json")
1325
+ if base_candidate not in seen:
1326
+ candidate_paths.append(base_candidate)
1327
+
1328
+ for credentials_path in candidate_paths:
1151
1329
  if os.path.exists(credentials_path):
1152
1330
  try:
1153
- with open(credentials_path, "r") as f:
1331
+ with open(credentials_path, "r", encoding="utf-8") as f:
1154
1332
  credentials = json.load(f)
1155
1333
 
1334
+ required = ("homeserver", "access_token", "user_id", "device_id")
1335
+ if not all(
1336
+ isinstance(credentials.get(k), str) and credentials.get(k).strip()
1337
+ for k in required
1338
+ ):
1339
+ print(
1340
+ f"❌ Error: credentials.json at {credentials_path} is missing required fields"
1341
+ )
1342
+ print(f"Run '{get_command('auth_login')}' to authenticate")
1343
+ return 1
1156
1344
  print(f"✅ Found credentials.json at: {credentials_path}")
1157
- print(f" Homeserver: {credentials.get('homeserver', 'Unknown')}")
1158
- print(f" User ID: {credentials.get('user_id', 'Unknown')}")
1159
- print(f" Device ID: {credentials.get('device_id', 'Unknown')}")
1345
+ print(f" Homeserver: {credentials.get('homeserver')}")
1346
+ print(f" User ID: {credentials.get('user_id')}")
1347
+ print(f" Device ID: {credentials.get('device_id')}")
1160
1348
  return 0
1161
1349
  except Exception as e:
1162
1350
  print(f"❌ Error reading credentials.json: {e}")
@@ -1169,15 +1357,21 @@ def handle_auth_status(args):
1169
1357
 
1170
1358
  def handle_auth_logout(args):
1171
1359
  """
1172
- Log out the bot from Matrix and remove local session artifacts.
1360
+ Log out the Matrix bot and remove local session artifacts.
1173
1361
 
1174
- Prompts for a verification password (unless provided via args.password), warns if the password was supplied on the command line, asks for confirmation unless args.yes is True, and then performs the logout by calling the logout_matrix_bot routine. On success the function returns 0; on failure or cancellation it returns 1. KeyboardInterrupt is treated as a cancellation and returns 1.
1362
+ Prompts for a verification password (unless a non-empty password is provided via args.password),
1363
+ optionally asks for interactive confirmation (skipped if args.yes is True), and attempts to clear
1364
+ local session data (credentials, E2EE store) and invalidate the bot's access token.
1175
1365
 
1176
1366
  Parameters:
1177
- args (argparse.Namespace): CLI arguments. Relevant attributes:
1178
- password (str | None): If provided and non-empty, used as the verification password.
1179
- If provided as an empty string or omitted, the function will prompt securely.
1180
- yes (bool): If True, skip the interactive confirmation prompt.
1367
+ args (argparse.Namespace): CLI arguments with the following relevant attributes:
1368
+ password (str | None): If a non-empty string is provided, it will be used as the
1369
+ verification password. If None or an empty string, the function prompts securely.
1370
+ yes (bool): If True, skip the confirmation prompt.
1371
+
1372
+ Returns:
1373
+ int: 0 on successful logout, 1 on failure or if the operation is cancelled (including
1374
+ KeyboardInterrupt).
1181
1375
  """
1182
1376
  import asyncio
1183
1377
 
@@ -1197,7 +1391,11 @@ def handle_auth_logout(args):
1197
1391
  # Handle password input
1198
1392
  password = getattr(args, "password", None)
1199
1393
 
1200
- if password is None or password == "":
1394
+ if (
1395
+ password is None
1396
+ or password
1397
+ == "" # nosec B105 (user-entered secret; prompting securely via getpass)
1398
+ ):
1201
1399
  # No --password flag or --password with no value, prompt securely
1202
1400
  import getpass
1203
1401
 
mmrelay/message_queue.py CHANGED
@@ -7,10 +7,13 @@ rate, respecting connection state and firmware constraints.
7
7
  """
8
8
 
9
9
  import asyncio
10
+ import contextlib
10
11
  import threading
11
12
  import time
13
+ from concurrent.futures import ThreadPoolExecutor
12
14
  from dataclasses import dataclass
13
- from queue import Empty, Queue
15
+ from functools import partial
16
+ from queue import Empty, Full, Queue
14
17
  from typing import Callable, Optional
15
18
 
16
19
  from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
@@ -50,20 +53,40 @@ class MessageQueue:
50
53
 
51
54
  def __init__(self):
52
55
  """
53
- Initialize the MessageQueue with an empty queue, state variables, and a thread lock for safe operation.
56
+ Create a new MessageQueue, initializing its internal queue, timing and state variables, and a thread lock.
57
+
58
+ Attributes:
59
+ _queue (Queue): Bounded FIFO holding queued messages (maxsize=MAX_QUEUE_SIZE).
60
+ _processor_task (Optional[asyncio.Task]): Async task that processes the queue, created when started.
61
+ _running (bool): Whether the processor is active.
62
+ _lock (threading.Lock): Protects start/stop and other state transitions.
63
+ _last_send_time (float): Wall-clock timestamp of the last successful send.
64
+ _last_send_mono (float): Monotonic timestamp of the last successful send (used for rate limiting).
65
+ _message_delay (float): Minimum delay between sends; starts at DEFAULT_MESSAGE_DELAY and may be adjusted.
66
+ _executor (Optional[concurrent.futures.ThreadPoolExecutor]): Dedicated single-worker executor for blocking send operations (created on start).
67
+ _in_flight (bool): True while a message send is actively running in the executor.
68
+ _has_current (bool): True when there is a current message being processed (even if not yet dispatched to the executor).
54
69
  """
55
- self._queue = Queue()
70
+ self._queue = Queue(maxsize=MAX_QUEUE_SIZE)
56
71
  self._processor_task = None
57
72
  self._running = False
58
73
  self._lock = threading.Lock()
59
74
  self._last_send_time = 0.0
75
+ self._last_send_mono = 0.0
60
76
  self._message_delay = DEFAULT_MESSAGE_DELAY
77
+ self._executor = None # Dedicated ThreadPoolExecutor for this MessageQueue
78
+ self._in_flight = False
79
+ self._has_current = False
80
+ self._dropped_messages = 0
61
81
 
62
82
  def start(self, message_delay: float = DEFAULT_MESSAGE_DELAY):
63
83
  """
64
- Start the message queue processor with a specified minimum delay between messages.
84
+ Activate the message queue processor with a minimum inter-message delay.
65
85
 
66
- If the provided delay is below the firmware-enforced minimum, the minimum is used instead. The processor task is started immediately if the asyncio event loop is running; otherwise, startup is deferred until the event loop becomes available.
86
+ Enables processing, sets the configured message delay (raised to MINIMUM_MESSAGE_DELAY if a smaller value is provided), creates a dedicated ThreadPoolExecutor for send operations if one does not exist, and starts the asyncio processor task immediately when a running event loop is available; if no running loop is available startup is deferred until a loop is present.
87
+
88
+ Parameters:
89
+ message_delay (float): Requested minimum delay between sends, in seconds. Values below MINIMUM_MESSAGE_DELAY are replaced with MINIMUM_MESSAGE_DELAY.
67
90
  """
68
91
  with self._lock:
69
92
  if self._running:
@@ -79,10 +102,19 @@ class MessageQueue:
79
102
  self._message_delay = message_delay
80
103
  self._running = True
81
104
 
105
+ # Create dedicated executor for this MessageQueue
106
+ if self._executor is None:
107
+ self._executor = ThreadPoolExecutor(
108
+ max_workers=1, thread_name_prefix=f"MessageQueue-{id(self)}"
109
+ )
110
+
82
111
  # Start the processor in the event loop
83
112
  try:
84
- loop = asyncio.get_event_loop()
85
- if loop.is_running():
113
+ try:
114
+ loop = asyncio.get_running_loop()
115
+ except RuntimeError:
116
+ loop = None
117
+ if loop and loop.is_running():
86
118
  self._processor_task = loop.create_task(self._process_queue())
87
119
  logger.info(
88
120
  f"Message queue started with {self._message_delay}s message delay"
@@ -100,7 +132,14 @@ class MessageQueue:
100
132
 
101
133
  def stop(self):
102
134
  """
103
- Stops the message queue processor and cancels the processing task if active.
135
+ Stop the message queue processor and clean up internal resources.
136
+
137
+ Cancels the background processor task (if running) and attempts to wait for it to finish on the task's owning event loop without blocking the caller's event loop. Shuts down the dedicated ThreadPoolExecutor used for blocking I/O; when called from an asyncio event loop the executor shutdown is performed on a background thread to avoid blocking. Clears internal state flags and resources so the queue can be restarted later.
138
+
139
+ Notes:
140
+ - This method is thread-safe.
141
+ - It may block briefly (the implementation waits up to ~1 second when awaiting task completion) but will avoid blocking the current asyncio event loop when possible.
142
+ - No exceptions are propagated for normal cancellation/shutdown paths; internal exceptions during shutdown are suppressed.
104
143
  """
105
144
  with self._lock:
106
145
  if not self._running:
@@ -110,8 +149,64 @@ class MessageQueue:
110
149
 
111
150
  if self._processor_task:
112
151
  self._processor_task.cancel()
152
+
153
+ # Wait for the task to complete on its owning loop
154
+ task_loop = self._processor_task.get_loop()
155
+ current_loop = None
156
+ with contextlib.suppress(RuntimeError):
157
+ current_loop = asyncio.get_running_loop()
158
+ if task_loop.is_closed():
159
+ # Owning loop is closed; nothing we can do to await it
160
+ pass
161
+ elif current_loop is task_loop:
162
+ # Avoid blocking the event loop thread; cancellation will finish naturally
163
+ pass
164
+ elif task_loop.is_running():
165
+ from asyncio import run_coroutine_threadsafe, shield
166
+
167
+ with contextlib.suppress(Exception):
168
+ fut = run_coroutine_threadsafe(
169
+ shield(self._processor_task), task_loop
170
+ )
171
+ # Wait for completion; ignore exceptions raised due to cancellation
172
+ fut.result(timeout=1.0)
173
+ else:
174
+ with contextlib.suppress(
175
+ asyncio.CancelledError, RuntimeError, Exception
176
+ ):
177
+ task_loop.run_until_complete(self._processor_task)
178
+
113
179
  self._processor_task = None
114
180
 
181
+ # Shut down our dedicated executor without blocking the event loop
182
+ if self._executor:
183
+ on_loop_thread = False
184
+ with contextlib.suppress(RuntimeError):
185
+ loop_chk = asyncio.get_running_loop()
186
+ on_loop_thread = loop_chk.is_running()
187
+
188
+ def _shutdown(exec_ref):
189
+ """
190
+ Shut down an executor, waiting for running tasks to finish; falls back for executors that don't support `cancel_futures`.
191
+
192
+ Attempts to call executor.shutdown(wait=True, cancel_futures=True) and, if that raises a TypeError (older Python versions or executors without the `cancel_futures` parameter), retries with executor.shutdown(wait=True). This call blocks until shutdown completes.
193
+ """
194
+ try:
195
+ exec_ref.shutdown(wait=True, cancel_futures=True)
196
+ except TypeError:
197
+ exec_ref.shutdown(wait=True)
198
+
199
+ if on_loop_thread:
200
+ threading.Thread(
201
+ target=_shutdown,
202
+ args=(self._executor,),
203
+ name="MessageQueueExecutorShutdown",
204
+ daemon=True,
205
+ ).start()
206
+ else:
207
+ _shutdown(self._executor)
208
+ self._executor = None
209
+
115
210
  logger.info("Message queue stopped")
116
211
 
117
212
  def enqueue(
@@ -119,18 +214,20 @@ class MessageQueue:
119
214
  send_function: Callable,
120
215
  *args,
121
216
  description: str = "",
122
- mapping_info: dict = None,
217
+ mapping_info: Optional[dict] = None,
123
218
  **kwargs,
124
219
  ) -> bool:
125
220
  """
126
- Adds a message to the queue for rate-limited, ordered sending.
221
+ Enqueue a message for ordered, rate-limited sending.
222
+
223
+ Ensures the queue processor is started (if an event loop is available) and attempts to add a QueuedMessage (containing the provided send function and its arguments) to the bounded in-memory queue. If the queue is not running or has reached capacity the message is not added and the method returns False. Optionally attach mapping_info metadata (used later to correlate sent messages with external IDs).
127
224
 
128
225
  Parameters:
129
- send_function (Callable): The function to call to send the message.
130
- *args: Positional arguments for the send function.
131
- description (str, optional): Human-readable description for logging purposes.
132
- mapping_info (dict, optional): Optional metadata for message mapping (e.g., replies or reactions).
133
- **kwargs: Keyword arguments for the send function.
226
+ send_function (Callable): Callable to execute when the message is sent.
227
+ *args: Positional arguments to pass to send_function.
228
+ description (str, optional): Human-readable description used for logging.
229
+ mapping_info (dict | None, optional): Optional metadata to record after a successful send.
230
+ **kwargs: Keyword arguments to pass to send_function.
134
231
 
135
232
  Returns:
136
233
  bool: True if the message was successfully enqueued; False if the queue is not running or is full.
@@ -142,16 +239,9 @@ class MessageQueue:
142
239
  with self._lock:
143
240
  if not self._running:
144
241
  # Refuse to send to prevent blocking the event loop
145
- logger.error(f"Queue not running, cannot send message: {description}")
146
242
  logger.error(
147
- "Application is in invalid state - message queue should be started before sending messages"
148
- )
149
- return False
150
-
151
- # Check queue size to prevent memory issues
152
- if self._queue.qsize() >= MAX_QUEUE_SIZE:
153
- logger.warning(
154
- f"Message queue full ({self._queue.qsize()}/{MAX_QUEUE_SIZE}), dropping message: {description}"
243
+ "Queue not running; cannot send message: %s. Start the message queue before sending.",
244
+ description,
155
245
  )
156
246
  return False
157
247
 
@@ -163,8 +253,15 @@ class MessageQueue:
163
253
  description=description,
164
254
  mapping_info=mapping_info,
165
255
  )
166
-
167
- self._queue.put(message)
256
+ # Enforce capacity via bounded queue
257
+ try:
258
+ self._queue.put_nowait(message)
259
+ except Full:
260
+ logger.warning(
261
+ f"Message queue full ({self._queue.qsize()}/{MAX_QUEUE_SIZE}), dropping message: {description}"
262
+ )
263
+ self._dropped_messages += 1
264
+ return False
168
265
  # Only log queue status when there are multiple messages
169
266
  queue_size = self._queue.qsize()
170
267
  if queue_size >= 2:
@@ -190,10 +287,21 @@ class MessageQueue:
190
287
 
191
288
  def get_status(self) -> dict:
192
289
  """
193
- Return a dictionary with the current status of the message queue, including running state, queue size, message delay, processor activity, last send time, and time since last send.
290
+ Return current status of the message queue.
291
+
292
+ Provides a snapshot useful for monitoring and debugging.
194
293
 
195
294
  Returns:
196
- dict: Status information about the message queue for debugging and monitoring.
295
+ dict: Mapping with the following keys:
296
+ - running (bool): Whether the queue processor is active.
297
+ - queue_size (int): Number of messages currently queued.
298
+ - message_delay (float): Configured minimum delay (seconds) between sends.
299
+ - processor_task_active (bool): True if the internal processor task exists and is not finished.
300
+ - last_send_time (float or None): Wall-clock time (seconds since the epoch) of the last successful send, or None if no send has occurred.
301
+ - time_since_last_send (float or None): Seconds elapsed since last_send_time, or None if no send has occurred.
302
+ - in_flight (bool): True when a message is currently being sent.
303
+ - dropped_messages (int): Number of messages dropped due to queue being full.
304
+ - default_msgs_to_keep (int): Default retention setting for message mappings.
197
305
  """
198
306
  return {
199
307
  "running": self._running,
@@ -203,10 +311,30 @@ class MessageQueue:
203
311
  and not self._processor_task.done(),
204
312
  "last_send_time": self._last_send_time,
205
313
  "time_since_last_send": (
206
- time.time() - self._last_send_time if self._last_send_time > 0 else None
314
+ time.monotonic() - self._last_send_mono
315
+ if self._last_send_mono > 0
316
+ else None
207
317
  ),
318
+ "in_flight": self._in_flight,
319
+ "dropped_messages": getattr(self, "_dropped_messages", 0),
320
+ "default_msgs_to_keep": DEFAULT_MSGS_TO_KEEP,
208
321
  }
209
322
 
323
+ async def drain(self, timeout: Optional[float] = None) -> bool:
324
+ """
325
+ Asynchronously wait until the queue has fully drained (no queued messages and no in-flight or current message) or until an optional timeout elapses.
326
+
327
+ If `timeout` is provided, it is interpreted in seconds. Returns True when the queue is empty and there are no messages being processed; returns False if the queue was stopped before draining or the timeout was reached.
328
+ """
329
+ deadline = (time.monotonic() + timeout) if timeout is not None else None
330
+ while (not self._queue.empty()) or self._in_flight or self._has_current:
331
+ if not self._running:
332
+ return False
333
+ if deadline is not None and time.monotonic() > deadline:
334
+ return False
335
+ await asyncio.sleep(0.1)
336
+ return True
337
+
210
338
  def ensure_processor_started(self):
211
339
  """
212
340
  Start the queue processor task if the queue is running and no processor task exists.
@@ -216,15 +344,14 @@ class MessageQueue:
216
344
  with self._lock:
217
345
  if self._running and self._processor_task is None:
218
346
  try:
219
- loop = asyncio.get_event_loop()
220
- if loop.is_running():
221
- self._processor_task = loop.create_task(self._process_queue())
222
- logger.info(
223
- f"Message queue processor started with {self._message_delay}s message delay"
224
- )
347
+ loop = asyncio.get_running_loop()
225
348
  except RuntimeError:
226
- # Still no event loop available
227
- pass
349
+ loop = None
350
+ if loop and loop.is_running():
351
+ self._processor_task = loop.create_task(self._process_queue())
352
+ logger.info(
353
+ f"Message queue processor started with {self._message_delay}s message delay"
354
+ )
228
355
 
229
356
  async def _process_queue(self):
230
357
  """
@@ -253,6 +380,7 @@ class MessageQueue:
253
380
  # Get next message (non-blocking)
254
381
  try:
255
382
  current_message = self._queue.get_nowait()
383
+ self._has_current = True
256
384
  except Empty:
257
385
  # No messages, wait a bit and continue
258
386
  await asyncio.sleep(0.1)
@@ -268,8 +396,8 @@ class MessageQueue:
268
396
  continue
269
397
 
270
398
  # Check if we need to wait for message delay (only if we've sent before)
271
- if self._last_send_time > 0:
272
- time_since_last = time.time() - self._last_send_time
399
+ if self._last_send_mono > 0:
400
+ time_since_last = time.monotonic() - self._last_send_mono
273
401
  if time_since_last < self._message_delay:
274
402
  wait_time = self._message_delay - time_since_last
275
403
  logger.debug(
@@ -280,20 +408,27 @@ class MessageQueue:
280
408
 
281
409
  # Send the message
282
410
  try:
411
+ self._in_flight = True
283
412
  logger.debug(
284
413
  f"Sending queued message: {current_message.description}"
285
414
  )
286
415
  # Run synchronous Meshtastic I/O operations in executor to prevent blocking event loop
287
- # Use lambda with default arguments to properly capture loop variables
288
- result = await asyncio.get_running_loop().run_in_executor(
289
- None,
290
- lambda msg=current_message: msg.send_function(
291
- *msg.args, **msg.kwargs
416
+ loop = asyncio.get_running_loop()
417
+ exec_ref = self._executor
418
+ if exec_ref is None:
419
+ raise RuntimeError("MessageQueue executor is not initialized")
420
+ result = await loop.run_in_executor(
421
+ exec_ref,
422
+ partial(
423
+ current_message.send_function,
424
+ *current_message.args,
425
+ **current_message.kwargs,
292
426
  ),
293
427
  )
294
428
 
295
429
  # Update last send time
296
430
  self._last_send_time = time.time()
431
+ self._last_send_mono = time.monotonic()
297
432
 
298
433
  if result is None:
299
434
  logger.warning(
@@ -318,6 +453,8 @@ class MessageQueue:
318
453
  # Mark task as done and clear current message
319
454
  self._queue.task_done()
320
455
  current_message = None
456
+ self._in_flight = False
457
+ self._has_current = False
321
458
 
322
459
  except asyncio.CancelledError:
323
460
  logger.debug("Message queue processor cancelled")
@@ -325,6 +462,10 @@ class MessageQueue:
325
462
  logger.warning(
326
463
  f"Message in flight was dropped during shutdown: {current_message.description}"
327
464
  )
465
+ with contextlib.suppress(Exception):
466
+ self._queue.task_done()
467
+ self._in_flight = False
468
+ self._has_current = False
328
469
  break
329
470
  except Exception as e:
330
471
  logger.error(f"Error in message queue processor: {e}")
@@ -332,10 +473,12 @@ class MessageQueue:
332
473
 
333
474
  def _should_send_message(self) -> bool:
334
475
  """
335
- Determine whether it is currently safe to send a message based on Meshtastic client connection and reconnection state.
476
+ Return True if it's currently safe to send a message over Meshtastic.
336
477
 
337
- Returns:
338
- bool: True if the client is connected and not reconnecting; False otherwise.
478
+ Checks that the Meshtastic client exists, is connected (supports callable or boolean
479
+ `is_connected`), and that a global reconnection flag is not set. Returns False otherwise.
480
+ If importing meshtastic utilities raises ImportError, logs a critical error, starts a
481
+ background thread to stop this MessageQueue, and returns False.
339
482
  """
340
483
  # Import here to avoid circular imports
341
484
  try:
@@ -367,18 +510,26 @@ class MessageQueue:
367
510
  logger.critical(
368
511
  f"Cannot import meshtastic_utils - serious application error: {e}. Stopping message queue."
369
512
  )
370
- self.stop()
513
+ # Stop asynchronously to avoid blocking the event loop thread.
514
+ threading.Thread(
515
+ target=self.stop, name="MessageQueueStopper", daemon=True
516
+ ).start()
371
517
  return False
372
518
 
373
519
  def _handle_message_mapping(self, result, mapping_info):
374
520
  """
375
- Update the message mapping database with information about a sent message and prune old mappings if configured.
521
+ Store a sent message's mapping (mesh ID Matrix event) and prune old mappings according to retention settings.
376
522
 
377
- Parameters:
378
- result: The result object from the send function, expected to have an `id` attribute.
379
- mapping_info (dict): Contains mapping details such as `matrix_event_id`, `room_id`, `text`, and optionally `meshnet` and `msgs_to_keep`.
523
+ If `mapping_info` contains the required keys (`matrix_event_id`, `room_id`, `text`), this will call the DB helpers to persist a mapping for `result.id` and then prune older mappings using `mapping_info["msgs_to_keep"]` if present (falls back to DEFAULT_MSGS_TO_KEEP).
380
524
 
381
- If required mapping fields are present, stores the mapping and prunes old entries based on the specified or default retention count.
525
+ Parameters:
526
+ result: The send function's result object; must expose an `id` attribute (the mesh message id).
527
+ mapping_info: Dict supplying mapping fields:
528
+ - matrix_event_id (str): Matrix event id to associate with the mesh message.
529
+ - room_id (str): Matrix room id for the event.
530
+ - text (str): The message text to store in the mapping.
531
+ - meshnet (optional): Mesh network identifier passed to the store function.
532
+ - msgs_to_keep (optional, int): Number of mappings to retain; if > 0, prune older entries.
382
533
  """
383
534
  try:
384
535
  # Import here to avoid circular imports
@@ -442,7 +593,7 @@ def queue_message(
442
593
  send_function: Callable,
443
594
  *args,
444
595
  description: str = "",
445
- mapping_info: dict = None,
596
+ mapping_info: Optional[dict] = None,
446
597
  **kwargs,
447
598
  ) -> bool:
448
599
  """
@@ -1,22 +1,23 @@
1
1
  matrix:
2
2
  homeserver: https://example.matrix.org
3
- access_token: reaalllllyloooooongsecretttttcodeeeeeeforrrrbot # See: https://t2bot.io/docs/access_tokens/
3
+ # Modern authentication using password (recommended)
4
+ # MMRelay will automatically create secure credentials.json on startup
5
+ password: your_matrix_password_here # Set your Matrix account password
6
+ # Security: After first successful start, remove this password from the file.
7
+ # Linux/macOS: chmod 600 ~/.mmrelay/config.yaml
8
+ # Windows: restrict file access to your user (e.g., via file Properties → Security)
9
+ # Never commit this file with a password to version control.
4
10
  bot_user_id: "@botuser:example.matrix.org"
5
11
 
6
- # Alternative: Automatic credentials creation (Docker-friendly)
7
- # If you provide password instead of access_token, MMRelay will automatically
8
- # create credentials.json on startup. Useful for Docker deployments.
9
- #password: your_matrix_password_here # Uncomment and set your Matrix password
10
-
11
12
  # End-to-End Encryption (E2EE) configuration
12
- # NOTE: E2EE requires credentials.json instead of access_token for new sessions
13
+ # E2EE is automatically enabled when using password-based authentication AND E2EE dependencies are installed (Linux/macOS only)
14
+ # NOTE: E2EE is not available on Windows due to dependency limitations
13
15
  #
14
16
  # SETUP INSTRUCTIONS:
15
- # 1. Install E2EE dependencies: pipx install 'mmrelay[e2e]'
16
- # 2. Enable E2EE in config: uncomment and set enabled: true below
17
- # 3. Create credentials: mmrelay auth login
18
- # 4. The auth login command will create credentials.json with your Matrix login
19
- # 5. Restart mmrelay - it will use credentials.json and enable E2EE automatically
17
+ # 1. Install E2EE dependencies: pipx install 'mmrelay[e2e]' (Linux/macOS only)
18
+ # 2. Set your password above and run MMRelay
19
+ # 3. MMRelay will automatically create credentials.json with E2EE support
20
+ # 4. For interactive setup, use: mmrelay auth login
20
21
  #
21
22
  #e2ee:
22
23
  # # Optional: When credentials.json is present, MMRelay auto-enables E2EE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mmrelay
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Bridge between Meshtastic mesh networks and Matrix chat rooms
5
5
  Home-page: https://github.com/jeremiah-k/meshtastic-matrix-relay
6
6
  Author: Geoff Whittington, Jeremiah K., and contributors
@@ -22,7 +22,7 @@ Requires-Dist: meshtastic>=2.6.4
22
22
  Requires-Dist: Pillow==11.3.0
23
23
  Requires-Dist: matrix-nio==0.25.2
24
24
  Requires-Dist: matplotlib==3.10.1
25
- Requires-Dist: requests==2.32.4
25
+ Requires-Dist: requests==2.32.5
26
26
  Requires-Dist: markdown==3.8.2
27
27
  Requires-Dist: bleach==6.2.0
28
28
  Requires-Dist: haversine==2.9.0
@@ -69,16 +69,16 @@ A powerful and easy-to-use relay between Meshtastic devices and Matrix chat room
69
69
  - ✨️ _Native Docker support_ ✨️
70
70
  - 🔐 **Matrix End-to-End Encryption (E2EE) support** 🔐 **NEW in v1.2!**
71
71
 
72
- **MMRelay v1.2** introduces full **Matrix End-to-End Encryption** support for secure communication in encrypted rooms. Messages are automatically encrypted/decrypted when communicating with encrypted Matrix rooms, with simple setup using `mmrelay auth login` or automatic credentials creation from config.yaml.
72
+ **MMRelay v1.2** introduces **Matrix End-to-End Encryption** support for secure communication in encrypted rooms. Messages are automatically encrypted/decrypted when communicating with encrypted Matrix rooms, with simple setup using `mmrelay auth login` or automatic credentials creation from config.yaml.
73
73
 
74
74
  ## Documentation
75
75
 
76
- Visit our [Wiki](https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki) for comprehensive guides and information.
77
-
78
76
  MMRelay supports multiple deployment methods including Docker, pip installation, and standalone executables. For complete setup instructions and all deployment options, see:
79
77
 
80
78
  - [Installation Instructions](docs/INSTRUCTIONS.md) - Setup and configuration guide
81
79
  - [Docker Guide](docs/DOCKER.md) - Docker deployment methods
80
+ - [What's New in v1.2](docs/WHATS_NEW_1.2.md) - New features and improvements
81
+ - [E2EE Setup Guide](docs/E2EE.md) - Matrix End-to-End Encryption configuration
82
82
 
83
83
  ---
84
84
 
@@ -1,5 +1,5 @@
1
- mmrelay/__init__.py,sha256=CcCX3oC0CS1MiZg2aWA5KXA2UXqYLXBT4GP3M07R-cs,120
2
- mmrelay/cli.py,sha256=LT_DVvhXFPCpGxU5RD4SxNNqtIJyVVD8bwMm9YSz2q8,55711
1
+ mmrelay/__init__.py,sha256=U6f4invRZI32-C9f6TasN4-6DMO0rRUEryNFO1iO20Y,120
2
+ mmrelay/cli.py,sha256=jfoUbvvRZ7q_axZN4y_SqIna1lVzlLz_7JNnvhCBuMk,63862
3
3
  mmrelay/cli_utils.py,sha256=z1TfgHM82NdTtO0toGyQrNdeB7F8khtzuLJYjvDG1j8,26380
4
4
  mmrelay/config.py,sha256=qajoYz_KCRWCw6ULAND0ysmHqw4Nbv_PjewNrW22O8o,32640
5
5
  mmrelay/db_utils.py,sha256=b_cuw4zOwGlvk4CljFh3SguqOi-TRCHW_s9RCt9gUsU,19920
@@ -8,7 +8,7 @@ mmrelay/log_utils.py,sha256=6O-dvt9K5uT89XkA74gcYYQJ5lADAk5U9IVL4mj1XwM,8764
8
8
  mmrelay/main.py,sha256=QwOLY6uLvFQZhHRlJrbp_D_-xDuEHQIgEsQ6ME_g1EI,16360
9
9
  mmrelay/matrix_utils.py,sha256=at0mME8nWwN-yxGGIlH4GblwGmrJd6DrjlNhM794scU,99146
10
10
  mmrelay/meshtastic_utils.py,sha256=86U8vQuWW7xR4E-_n-d7vlQhQ40gO-tJGZcVjzoeFhc,43027
11
- mmrelay/message_queue.py,sha256=tHKziIPwQ9Xm7qpITyR1r2e-ftdn4ZivdZhek9SmMcQ,18703
11
+ mmrelay/message_queue.py,sha256=HvD4oH_X4K3f4mjpWgWrxO_KkYykuqpOpEeO37ZeQOw,27961
12
12
  mmrelay/plugin_loader.py,sha256=6SD7eoO0VHnsEPI_YrrnrbeHAjC7okmyUpaA4Eu5AHo,41718
13
13
  mmrelay/setup_utils.py,sha256=EAVPmKHQmlXnxMcrkhlU27fBPjJCiwTVSLZdbEFsWLQ,22095
14
14
  mmrelay/constants/__init__.py,sha256=M8AXeIcS1JuS8OwmfTmhcCOkAz5XmWlNQ53GBxYOx94,1494
@@ -36,10 +36,10 @@ mmrelay/tools/mmrelay.service,sha256=3vqK1VbfXvVftkTrTEOan77aTHeOT36hIAL7HqJsmTg
36
36
  mmrelay/tools/sample-docker-compose-prebuilt.yaml,sha256=NWV-g3RwiFlYfjgGL5t9DtInoJJDUSaTBy4w1ICk81A,3136
37
37
  mmrelay/tools/sample-docker-compose.yaml,sha256=PHt0MGrzUAzxBovtf1TZyrvJ9YK-ZpdSyyp5Tmp8zIY,2543
38
38
  mmrelay/tools/sample.env,sha256=RP-o3rX3jnEIrVG2rqCZq31O1yRXou4HcGrXWLVbKKw,311
39
- mmrelay/tools/sample_config.yaml,sha256=wQnEhL5St7Xqeb--ClJQVcbVXu_n69fPHuF2zSB0Zu4,6250
40
- mmrelay-1.2.0.dist-info/licenses/LICENSE,sha256=aB_07MhnK-bL5WLI1ucXLUSdW_yBVoepPRYB0kaAOl8,35204
41
- mmrelay-1.2.0.dist-info/METADATA,sha256=RvrjFahRkYXAokF54_gLy24oD2pgjjvQEYjcm0oi9BM,6143
42
- mmrelay-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
43
- mmrelay-1.2.0.dist-info/entry_points.txt,sha256=SJZwGUOEpQ-qx4H8UL4xKFnKeInGUaZNW1I0ddjK7Ws,45
44
- mmrelay-1.2.0.dist-info/top_level.txt,sha256=B_ZLCRm7NYAmI3PipRUyHGymP-C-q16LSeMGzmqJfo4,8
45
- mmrelay-1.2.0.dist-info/RECORD,,
39
+ mmrelay/tools/sample_config.yaml,sha256=UWHAUdnPNjKlEugcOntb2Dqr94zxZ7j5l3dOw9vt5L4,6393
40
+ mmrelay-1.2.1.dist-info/licenses/LICENSE,sha256=aB_07MhnK-bL5WLI1ucXLUSdW_yBVoepPRYB0kaAOl8,35204
41
+ mmrelay-1.2.1.dist-info/METADATA,sha256=BbRn_RHc-xSBPwrVld9Xkpp2BZVnXpYVd7aljt1dzhA,6176
42
+ mmrelay-1.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
43
+ mmrelay-1.2.1.dist-info/entry_points.txt,sha256=SJZwGUOEpQ-qx4H8UL4xKFnKeInGUaZNW1I0ddjK7Ws,45
44
+ mmrelay-1.2.1.dist-info/top_level.txt,sha256=B_ZLCRm7NYAmI3PipRUyHGymP-C-q16LSeMGzmqJfo4,8
45
+ mmrelay-1.2.1.dist-info/RECORD,,