mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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 +1 -1
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +735 -135
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +198 -71
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +6 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1069 -293
- mmrelay/meshtastic_utils.py +350 -206
- mmrelay/message_queue.py +212 -62
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +43 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +324 -129
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +14 -13
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/METADATA +11 -11
- mmrelay-1.2.2.dist-info/RECORD +48 -0
- mmrelay-1.2.0.dist-info/RECORD +0 -45
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/top_level.txt +0 -0
mmrelay/cli.py
CHANGED
|
@@ -7,6 +7,9 @@ import importlib.resources
|
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
9
9
|
import sys
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
10
13
|
|
|
11
14
|
# Import version from package
|
|
12
15
|
from mmrelay import __version__
|
|
@@ -131,6 +134,11 @@ def parse_arguments():
|
|
|
131
134
|
help="Validate configuration file",
|
|
132
135
|
description="Check configuration file syntax and completeness",
|
|
133
136
|
)
|
|
137
|
+
config_subparsers.add_parser(
|
|
138
|
+
"diagnose",
|
|
139
|
+
help="Diagnose configuration system issues",
|
|
140
|
+
description="Test config generation capabilities and troubleshoot platform-specific issues",
|
|
141
|
+
)
|
|
134
142
|
|
|
135
143
|
# AUTH group
|
|
136
144
|
auth_parser = subparsers.add_parser(
|
|
@@ -141,11 +149,24 @@ def parse_arguments():
|
|
|
141
149
|
auth_subparsers = auth_parser.add_subparsers(
|
|
142
150
|
dest="auth_command", help="Auth commands"
|
|
143
151
|
)
|
|
144
|
-
auth_subparsers.add_parser(
|
|
152
|
+
login_parser = auth_subparsers.add_parser(
|
|
145
153
|
"login",
|
|
146
154
|
help="Authenticate with Matrix",
|
|
147
155
|
description="Set up Matrix authentication for E2EE support",
|
|
148
156
|
)
|
|
157
|
+
login_parser.add_argument(
|
|
158
|
+
"--homeserver",
|
|
159
|
+
help="Matrix homeserver URL (e.g., https://matrix.org). If provided, --username and --password are also required.",
|
|
160
|
+
)
|
|
161
|
+
login_parser.add_argument(
|
|
162
|
+
"--username",
|
|
163
|
+
help="Matrix username (with or without @ and :server). If provided, --homeserver and --password are also required.",
|
|
164
|
+
)
|
|
165
|
+
login_parser.add_argument(
|
|
166
|
+
"--password",
|
|
167
|
+
metavar="PWD",
|
|
168
|
+
help="Matrix password (can be empty). If provided, --homeserver and --username are also required. For security, prefer interactive mode.",
|
|
169
|
+
)
|
|
149
170
|
|
|
150
171
|
auth_subparsers.add_parser(
|
|
151
172
|
"status",
|
|
@@ -189,9 +210,10 @@ def parse_arguments():
|
|
|
189
210
|
|
|
190
211
|
# Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
|
|
191
212
|
args, unknown = parser.parse_known_args()
|
|
192
|
-
# If there are unknown arguments and we're not in a test
|
|
193
|
-
|
|
194
|
-
|
|
213
|
+
# If there are unknown arguments and we're not in a test invocation, warn about them
|
|
214
|
+
# Heuristic: suppress warning when pytest appears in argv (unit tests may pass extra args)
|
|
215
|
+
if unknown and not any("pytest" in arg or "py.test" in arg for arg in sys.argv):
|
|
216
|
+
print(f"Warning: Unknown arguments ignored: {unknown}", file=sys.stderr)
|
|
195
217
|
|
|
196
218
|
return args
|
|
197
219
|
|
|
@@ -215,14 +237,16 @@ def print_version():
|
|
|
215
237
|
|
|
216
238
|
def _validate_e2ee_dependencies():
|
|
217
239
|
"""
|
|
218
|
-
Check whether end-to-end encryption (E2EE)
|
|
219
|
-
|
|
220
|
-
Performs a platform check and attempts to import required packages (python-olm, nio.crypto.OlmDevice,
|
|
221
|
-
and nio.store.SqliteStore). Prints a short user-facing status message and guidance.
|
|
222
|
-
|
|
240
|
+
Check whether end-to-end encryption (E2EE) is usable on the current platform.
|
|
241
|
+
|
|
223
242
|
Returns:
|
|
224
|
-
bool: True if the platform
|
|
225
|
-
|
|
243
|
+
bool: True if the platform is supported and required E2EE libraries can be imported;
|
|
244
|
+
False otherwise.
|
|
245
|
+
|
|
246
|
+
Notes:
|
|
247
|
+
- This function performs only local checks (platform and importability) and does not perform
|
|
248
|
+
network I/O.
|
|
249
|
+
- It emits user-facing messages to indicate missing platform support or missing dependencies.
|
|
226
250
|
"""
|
|
227
251
|
if sys.platform == WINDOWS_PLATFORM:
|
|
228
252
|
print("❌ Error: E2EE is not supported on Windows")
|
|
@@ -239,42 +263,34 @@ def _validate_e2ee_dependencies():
|
|
|
239
263
|
print("✅ E2EE dependencies are installed")
|
|
240
264
|
return True
|
|
241
265
|
except ImportError:
|
|
242
|
-
print("❌ Error: E2EE
|
|
266
|
+
print("❌ Error: E2EE dependencies not installed")
|
|
267
|
+
print(" End-to-end encryption features require additional dependencies")
|
|
243
268
|
print(" Install E2EE support: pipx install 'mmrelay[e2e]'")
|
|
244
269
|
return False
|
|
245
270
|
|
|
246
271
|
|
|
247
272
|
def _validate_credentials_json(config_path):
|
|
248
273
|
"""
|
|
249
|
-
Validate that a credentials.json file exists
|
|
274
|
+
Validate that a credentials.json file exists (adjacent to config_path or in the base directory) and contains the required Matrix session fields.
|
|
250
275
|
|
|
251
|
-
|
|
276
|
+
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
277
|
|
|
253
278
|
Parameters:
|
|
254
279
|
config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
|
|
255
280
|
|
|
256
281
|
Returns:
|
|
257
|
-
bool: True if a
|
|
282
|
+
bool: True if a credentials.json was found and contains all required non-empty fields; False otherwise.
|
|
258
283
|
"""
|
|
259
284
|
try:
|
|
260
285
|
import json
|
|
261
286
|
|
|
262
|
-
# Look for credentials.json
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
287
|
+
# Look for credentials.json using helper function
|
|
288
|
+
credentials_path = _find_credentials_json_path(config_path)
|
|
289
|
+
if not credentials_path:
|
|
290
|
+
return False
|
|
275
291
|
|
|
276
292
|
# Load and validate credentials
|
|
277
|
-
with open(credentials_path, "r") as f:
|
|
293
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
278
294
|
credentials = json.load(f)
|
|
279
295
|
|
|
280
296
|
# Check for required fields
|
|
@@ -282,7 +298,7 @@ def _validate_credentials_json(config_path):
|
|
|
282
298
|
missing_fields = [
|
|
283
299
|
field
|
|
284
300
|
for field in required_fields
|
|
285
|
-
if
|
|
301
|
+
if not _is_valid_non_empty_string((credentials or {}).get(field))
|
|
286
302
|
]
|
|
287
303
|
|
|
288
304
|
if missing_fields:
|
|
@@ -298,6 +314,52 @@ def _validate_credentials_json(config_path):
|
|
|
298
314
|
return False
|
|
299
315
|
|
|
300
316
|
|
|
317
|
+
def _is_valid_non_empty_string(value) -> bool:
|
|
318
|
+
"""
|
|
319
|
+
Return True if value is a string containing non-whitespace characters.
|
|
320
|
+
|
|
321
|
+
Checks that the input is an instance of `str` and that stripping whitespace
|
|
322
|
+
does not produce an empty string.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
bool: True when value is a non-empty, non-whitespace-only string; otherwise False.
|
|
326
|
+
"""
|
|
327
|
+
return isinstance(value, str) and value.strip() != ""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _has_valid_password_auth(matrix_section):
|
|
331
|
+
"""
|
|
332
|
+
Return True if the given Matrix config section contains valid password-based authentication settings.
|
|
333
|
+
|
|
334
|
+
The function expects matrix_section to be a dict-like mapping from configuration keys to values.
|
|
335
|
+
It validates that:
|
|
336
|
+
- `homeserver` and `bot_user_id` are present and are non-empty strings (after trimming),
|
|
337
|
+
- `password` is present and is a string (it may be an empty string, which is accepted).
|
|
338
|
+
|
|
339
|
+
If matrix_section is not a dict, the function returns False.
|
|
340
|
+
|
|
341
|
+
Parameters:
|
|
342
|
+
matrix_section: dict-like Matrix configuration section (may be the parsed "matrix" config).
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
bool: True when password-based authentication is correctly configured as described above; otherwise False.
|
|
346
|
+
"""
|
|
347
|
+
if not isinstance(matrix_section, Mapping):
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
pwd = matrix_section.get("password")
|
|
351
|
+
homeserver = matrix_section.get(CONFIG_KEY_HOMESERVER)
|
|
352
|
+
bot_user_id = matrix_section.get(CONFIG_KEY_BOT_USER_ID)
|
|
353
|
+
|
|
354
|
+
# Allow empty password strings (some environments legitimately use empty passwords).
|
|
355
|
+
# Homeserver and bot_user_id must still be valid non-empty strings.
|
|
356
|
+
return (
|
|
357
|
+
isinstance(pwd, str)
|
|
358
|
+
and _is_valid_non_empty_string(homeserver)
|
|
359
|
+
and _is_valid_non_empty_string(bot_user_id)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
301
363
|
def _validate_matrix_authentication(config_path, matrix_section):
|
|
302
364
|
"""
|
|
303
365
|
Determine whether Matrix authentication is configured and usable.
|
|
@@ -323,7 +385,10 @@ def _validate_matrix_authentication(config_path, matrix_section):
|
|
|
323
385
|
and whether E2EE support is available.
|
|
324
386
|
"""
|
|
325
387
|
has_valid_credentials = _validate_credentials_json(config_path)
|
|
326
|
-
|
|
388
|
+
token = (matrix_section or {}).get(CONFIG_KEY_ACCESS_TOKEN)
|
|
389
|
+
has_access_token = _is_valid_non_empty_string(token)
|
|
390
|
+
|
|
391
|
+
has_password = _has_valid_password_auth(matrix_section)
|
|
327
392
|
|
|
328
393
|
if has_valid_credentials:
|
|
329
394
|
print("✅ Using credentials.json for Matrix authentication")
|
|
@@ -331,8 +396,16 @@ def _validate_matrix_authentication(config_path, matrix_section):
|
|
|
331
396
|
print(" E2EE support available (if enabled)")
|
|
332
397
|
return True
|
|
333
398
|
|
|
399
|
+
elif has_password:
|
|
400
|
+
print(
|
|
401
|
+
"✅ Using password in config for initial authentication (credentials.json will be created on first run)"
|
|
402
|
+
)
|
|
403
|
+
print(f" {msg_for_e2ee_support()}")
|
|
404
|
+
return True
|
|
334
405
|
elif has_access_token:
|
|
335
|
-
print(
|
|
406
|
+
print(
|
|
407
|
+
"✅ Using access_token for Matrix authentication (deprecated — consider 'mmrelay auth login' to create credentials.json)"
|
|
408
|
+
)
|
|
336
409
|
print(f" {msg_for_e2ee_support()}")
|
|
337
410
|
return True
|
|
338
411
|
|
|
@@ -388,7 +461,7 @@ def _validate_e2ee_config(config, matrix_section, config_path):
|
|
|
388
461
|
)
|
|
389
462
|
if store_path:
|
|
390
463
|
expanded_path = os.path.expanduser(store_path)
|
|
391
|
-
if not os.path.exists(
|
|
464
|
+
if not os.path.exists(expanded_path):
|
|
392
465
|
print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
|
|
393
466
|
|
|
394
467
|
print("✅ E2EE configuration is valid")
|
|
@@ -470,19 +543,9 @@ def _analyze_e2ee_setup(config, config_path):
|
|
|
470
543
|
"Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
|
|
471
544
|
)
|
|
472
545
|
|
|
473
|
-
# Check credentials
|
|
474
|
-
|
|
475
|
-
|
|
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)
|
|
546
|
+
# Check credentials file existence
|
|
547
|
+
credentials_path = _find_credentials_json_path(config_path)
|
|
548
|
+
analysis["credentials_available"] = bool(credentials_path)
|
|
486
549
|
|
|
487
550
|
if not analysis["credentials_available"]:
|
|
488
551
|
analysis["recommendations"].append(
|
|
@@ -506,54 +569,89 @@ def _analyze_e2ee_setup(config, config_path):
|
|
|
506
569
|
return analysis
|
|
507
570
|
|
|
508
571
|
|
|
572
|
+
def _find_credentials_json_path(config_path: str | None) -> str | None:
|
|
573
|
+
"""
|
|
574
|
+
Return the filesystem path to a credentials.json file if one can be found, otherwise None.
|
|
575
|
+
|
|
576
|
+
Search order:
|
|
577
|
+
1. A credentials.json file located in the same directory as the provided config_path.
|
|
578
|
+
2. A credentials.json file in the application's base directory (get_base_dir()).
|
|
579
|
+
|
|
580
|
+
Parameters:
|
|
581
|
+
config_path (str | None): Path to the configuration file used to derive the adjacent credentials.json location.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
str | None: Absolute path to the discovered credentials.json, or None if no file is found.
|
|
585
|
+
"""
|
|
586
|
+
if not config_path:
|
|
587
|
+
from mmrelay.config import get_base_dir
|
|
588
|
+
|
|
589
|
+
standard = os.path.join(get_base_dir(), "credentials.json")
|
|
590
|
+
return standard if os.path.exists(standard) else None
|
|
591
|
+
|
|
592
|
+
config_dir = os.path.dirname(config_path)
|
|
593
|
+
candidate = os.path.join(config_dir, "credentials.json")
|
|
594
|
+
if os.path.exists(candidate):
|
|
595
|
+
return candidate
|
|
596
|
+
from mmrelay.config import get_base_dir
|
|
597
|
+
|
|
598
|
+
standard = os.path.join(get_base_dir(), "credentials.json")
|
|
599
|
+
return standard if os.path.exists(standard) else None
|
|
600
|
+
|
|
601
|
+
|
|
509
602
|
def _print_unified_e2ee_analysis(e2ee_status):
|
|
510
603
|
"""
|
|
511
|
-
Print a concise, user-facing analysis of E2EE readiness
|
|
604
|
+
Print a concise, user-facing analysis of E2EE readiness.
|
|
512
605
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
"ready", prints actionable
|
|
606
|
+
Given a status dictionary produced by the E2EE analysis routines, prints platform support,
|
|
607
|
+
dependency availability, whether E2EE is enabled in configuration, whether credentials.json
|
|
608
|
+
is available, and the overall status. If the overall status is not "ready", prints actionable
|
|
609
|
+
fix instructions.
|
|
516
610
|
|
|
517
611
|
Parameters:
|
|
518
|
-
e2ee_status (dict): Status dictionary
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
612
|
+
e2ee_status (dict): Status dictionary with (at least) the following keys:
|
|
613
|
+
- platform_supported (bool): whether the current OS/platform supports E2EE.
|
|
614
|
+
- dependencies_installed or dependencies_available (bool): whether required E2EE
|
|
615
|
+
Python packages and runtime dependencies are present.
|
|
616
|
+
- enabled or config_enabled (bool): whether E2EE is enabled in the configuration.
|
|
617
|
+
- credentials_available (bool): whether a usable credentials.json is present.
|
|
618
|
+
- overall_status (str): high-level status ("ready", "disabled", "incomplete", etc.).
|
|
525
619
|
"""
|
|
526
620
|
print("\n🔐 E2EE Configuration Analysis:")
|
|
527
621
|
|
|
528
622
|
# Platform support
|
|
529
|
-
if e2ee_status
|
|
623
|
+
if e2ee_status.get("platform_supported", True):
|
|
530
624
|
print("✅ Platform: E2EE supported")
|
|
531
625
|
else:
|
|
532
626
|
print("❌ Platform: E2EE not supported on Windows")
|
|
533
627
|
|
|
534
628
|
# Dependencies
|
|
535
|
-
if e2ee_status
|
|
629
|
+
if e2ee_status.get(
|
|
630
|
+
"dependencies_installed", e2ee_status.get("dependencies_available", False)
|
|
631
|
+
):
|
|
536
632
|
print("✅ Dependencies: E2EE dependencies installed")
|
|
537
633
|
else:
|
|
538
634
|
print("❌ Dependencies: E2EE dependencies not fully installed")
|
|
539
635
|
|
|
540
636
|
# Configuration
|
|
541
|
-
if e2ee_status
|
|
637
|
+
if e2ee_status.get("enabled", e2ee_status.get("config_enabled", False)):
|
|
542
638
|
print("✅ Configuration: E2EE enabled")
|
|
543
639
|
else:
|
|
544
640
|
print("❌ Configuration: E2EE disabled")
|
|
545
641
|
|
|
546
642
|
# Authentication
|
|
547
|
-
if e2ee_status
|
|
643
|
+
if e2ee_status.get("credentials_available", False):
|
|
548
644
|
print("✅ Authentication: credentials.json found")
|
|
549
645
|
else:
|
|
550
646
|
print("❌ Authentication: credentials.json not found")
|
|
551
647
|
|
|
552
648
|
# Overall status
|
|
553
|
-
print(
|
|
649
|
+
print(
|
|
650
|
+
f"\n📊 Overall Status: {e2ee_status.get('overall_status', 'unknown').upper()}"
|
|
651
|
+
)
|
|
554
652
|
|
|
555
653
|
# Show fix instructions if needed
|
|
556
|
-
if e2ee_status
|
|
654
|
+
if e2ee_status.get("overall_status") != "ready":
|
|
557
655
|
from mmrelay.e2ee_utils import get_e2ee_fix_instructions
|
|
558
656
|
|
|
559
657
|
instructions = get_e2ee_fix_instructions(e2ee_status)
|
|
@@ -682,23 +780,18 @@ def check_config(args=None):
|
|
|
682
780
|
"""
|
|
683
781
|
Validate the application's YAML configuration file and its required sections.
|
|
684
782
|
|
|
685
|
-
Performs these checks:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
-
|
|
690
|
-
|
|
691
|
-
- Ensures matrix_rooms exists, is a non-empty list, and each room is a dict containing an id.
|
|
692
|
-
- 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
|
-
- Validates optional meshtastic fields and types (broadcast_enabled, detection_sensor, message_delay >= 2.0, meshnet_name) and reports missing optional settings as guidance.
|
|
694
|
-
- Warns if a deprecated db section is present.
|
|
695
|
-
- Prints a short environment summary on success.
|
|
783
|
+
Performs these checks: locates the first existing config file from get_config_paths(args),
|
|
784
|
+
validates YAML syntax and non-empty content, verifies Matrix authentication (prefers
|
|
785
|
+
credentials.json but accepts access_token or password in the matrix section), performs
|
|
786
|
+
E2EE configuration and dependency checks, validates matrix_rooms and meshtastic sections
|
|
787
|
+
(including connection-specific requirements), checks a set of optional meshtastic settings
|
|
788
|
+
(and value constraints), and warns about deprecated sections.
|
|
696
789
|
|
|
697
790
|
Side effects:
|
|
698
|
-
- Prints errors, warnings, and status messages to stdout.
|
|
791
|
+
- Prints human-readable errors, warnings, and status messages to stdout.
|
|
699
792
|
|
|
700
793
|
Parameters:
|
|
701
|
-
args (argparse.Namespace | None): Parsed CLI arguments
|
|
794
|
+
args (argparse.Namespace | None): Parsed CLI arguments; if None, CLI arguments are parsed internally.
|
|
702
795
|
|
|
703
796
|
Returns:
|
|
704
797
|
bool: True if a configuration file was found and passed all checks; False otherwise.
|
|
@@ -717,7 +810,7 @@ def check_config(args=None):
|
|
|
717
810
|
config_path = path
|
|
718
811
|
print(f"Found configuration file at: {config_path}")
|
|
719
812
|
try:
|
|
720
|
-
with open(config_path, "r") as f:
|
|
813
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
721
814
|
config_content = f.read()
|
|
722
815
|
|
|
723
816
|
# Validate YAML syntax first
|
|
@@ -748,6 +841,9 @@ def check_config(args=None):
|
|
|
748
841
|
# Create empty matrix section if missing - no fields required
|
|
749
842
|
config[CONFIG_SECTION_MATRIX] = {}
|
|
750
843
|
matrix_section = config[CONFIG_SECTION_MATRIX]
|
|
844
|
+
if not isinstance(matrix_section, dict):
|
|
845
|
+
print("Error: 'matrix' section must be a mapping (YAML object)")
|
|
846
|
+
return False
|
|
751
847
|
required_matrix_fields = (
|
|
752
848
|
[]
|
|
753
849
|
) # No fields required from config when using credentials.json
|
|
@@ -756,22 +852,37 @@ def check_config(args=None):
|
|
|
756
852
|
if CONFIG_SECTION_MATRIX not in config:
|
|
757
853
|
print("Error: Missing 'matrix' section in config")
|
|
758
854
|
print(
|
|
759
|
-
" Either add matrix section with access_token and bot_user_id,"
|
|
855
|
+
" Either add matrix section with access_token or password and bot_user_id,"
|
|
760
856
|
)
|
|
761
857
|
print(f" {msg_or_run_auth_login()}")
|
|
762
858
|
return False
|
|
763
859
|
|
|
764
860
|
matrix_section = config[CONFIG_SECTION_MATRIX]
|
|
861
|
+
if not isinstance(matrix_section, dict):
|
|
862
|
+
print("Error: 'matrix' section must be a mapping (YAML object)")
|
|
863
|
+
return False
|
|
864
|
+
|
|
765
865
|
required_matrix_fields = [
|
|
766
866
|
CONFIG_KEY_HOMESERVER,
|
|
767
|
-
CONFIG_KEY_ACCESS_TOKEN,
|
|
768
867
|
CONFIG_KEY_BOT_USER_ID,
|
|
769
868
|
]
|
|
869
|
+
token = matrix_section.get(CONFIG_KEY_ACCESS_TOKEN)
|
|
870
|
+
pwd = matrix_section.get("password")
|
|
871
|
+
has_token = _is_valid_non_empty_string(token)
|
|
872
|
+
# Allow explicitly empty password strings; require the value to be a string
|
|
873
|
+
# (reject unquoted numeric types)
|
|
874
|
+
has_password = isinstance(pwd, str)
|
|
875
|
+
if not (has_token or has_password):
|
|
876
|
+
print(
|
|
877
|
+
"Error: Missing authentication in 'matrix' section: provide 'access_token' or 'password'"
|
|
878
|
+
)
|
|
879
|
+
print(f" {msg_or_run_auth_login()}")
|
|
880
|
+
return False
|
|
770
881
|
|
|
771
882
|
missing_matrix_fields = [
|
|
772
883
|
field
|
|
773
884
|
for field in required_matrix_fields
|
|
774
|
-
if
|
|
885
|
+
if not _is_valid_non_empty_string(matrix_section.get(field))
|
|
775
886
|
]
|
|
776
887
|
|
|
777
888
|
if missing_matrix_fields:
|
|
@@ -929,7 +1040,8 @@ def check_config(args=None):
|
|
|
929
1040
|
# Special validation for message_delay
|
|
930
1041
|
if option == "message_delay" and value < 2.0:
|
|
931
1042
|
print(
|
|
932
|
-
f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}"
|
|
1043
|
+
f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}",
|
|
1044
|
+
file=sys.stderr,
|
|
933
1045
|
)
|
|
934
1046
|
return False
|
|
935
1047
|
else:
|
|
@@ -943,16 +1055,23 @@ def check_config(args=None):
|
|
|
943
1055
|
# Check for deprecated db section
|
|
944
1056
|
if "db" in config:
|
|
945
1057
|
print(
|
|
946
|
-
"\nWarning: 'db' section is deprecated. Please use 'database' instead."
|
|
1058
|
+
"\nWarning: 'db' section is deprecated. Please use 'database' instead.",
|
|
1059
|
+
file=sys.stderr,
|
|
947
1060
|
)
|
|
948
1061
|
print(
|
|
949
|
-
"This option still works but may be removed in future versions.\n"
|
|
1062
|
+
"This option still works but may be removed in future versions.\n",
|
|
1063
|
+
file=sys.stderr,
|
|
950
1064
|
)
|
|
951
1065
|
|
|
952
1066
|
print("\n✅ Configuration file is valid!")
|
|
953
1067
|
return True
|
|
1068
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
1069
|
+
print(
|
|
1070
|
+
f"Error checking configuration: {e.__class__.__name__}: {e}",
|
|
1071
|
+
file=sys.stderr,
|
|
1072
|
+
)
|
|
954
1073
|
except Exception as e:
|
|
955
|
-
print(f"Error checking configuration: {e}")
|
|
1074
|
+
print(f"Error checking configuration: {e}", file=sys.stderr)
|
|
956
1075
|
return False
|
|
957
1076
|
|
|
958
1077
|
print("Error: No configuration file found in any of the following locations:")
|
|
@@ -977,8 +1096,27 @@ def main():
|
|
|
977
1096
|
int: Exit code (0 on success, non-zero on failure).
|
|
978
1097
|
"""
|
|
979
1098
|
try:
|
|
1099
|
+
# Set up Windows console for better compatibility
|
|
1100
|
+
try:
|
|
1101
|
+
from mmrelay.windows_utils import setup_windows_console
|
|
1102
|
+
|
|
1103
|
+
setup_windows_console()
|
|
1104
|
+
except (ImportError, OSError, AttributeError):
|
|
1105
|
+
# windows_utils not available or Windows console setup failed
|
|
1106
|
+
# This is intentional - we want to continue if Windows utils fail
|
|
1107
|
+
pass
|
|
1108
|
+
|
|
980
1109
|
args = parse_arguments()
|
|
981
1110
|
|
|
1111
|
+
# Handle the --data-dir option
|
|
1112
|
+
if args and args.data_dir:
|
|
1113
|
+
import mmrelay.config
|
|
1114
|
+
|
|
1115
|
+
# Set the global custom_data_dir variable
|
|
1116
|
+
mmrelay.config.custom_data_dir = os.path.abspath(args.data_dir)
|
|
1117
|
+
# Create the directory if it doesn't exist
|
|
1118
|
+
os.makedirs(mmrelay.config.custom_data_dir, exist_ok=True)
|
|
1119
|
+
|
|
982
1120
|
# Handle subcommands first (modern interface)
|
|
983
1121
|
if hasattr(args, "command") and args.command:
|
|
984
1122
|
return handle_subcommand(args)
|
|
@@ -1019,20 +1157,33 @@ def main():
|
|
|
1019
1157
|
print(f"Error importing main module: {e}")
|
|
1020
1158
|
return 1
|
|
1021
1159
|
|
|
1160
|
+
except (OSError, PermissionError, KeyboardInterrupt) as e:
|
|
1161
|
+
# Handle common system-level errors
|
|
1162
|
+
print(f"System error: {e.__class__.__name__}: {e}", file=sys.stderr)
|
|
1163
|
+
return 1
|
|
1022
1164
|
except Exception as e:
|
|
1023
|
-
|
|
1165
|
+
# Default error message
|
|
1166
|
+
error_msg = f"Unexpected error: {e.__class__.__name__}: {e}"
|
|
1167
|
+
# Provide Windows-specific error guidance if available
|
|
1168
|
+
try:
|
|
1169
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1170
|
+
|
|
1171
|
+
if is_windows():
|
|
1172
|
+
error_msg = f"Error: {get_windows_error_message(e)}"
|
|
1173
|
+
except ImportError:
|
|
1174
|
+
pass # Use default message
|
|
1175
|
+
print(error_msg, file=sys.stderr)
|
|
1024
1176
|
return 1
|
|
1025
1177
|
|
|
1026
1178
|
|
|
1027
1179
|
def handle_subcommand(args):
|
|
1028
1180
|
"""
|
|
1029
|
-
Dispatch the modern grouped CLI subcommand to
|
|
1030
|
-
|
|
1031
|
-
Parameters:
|
|
1032
|
-
args (argparse.Namespace): Parsed CLI arguments (as produced by parse_arguments()). Must have a `command` attribute with one of: "config", "auth", or "service".
|
|
1181
|
+
Dispatch the modern grouped CLI subcommand to the appropriate handler and return an exit code.
|
|
1033
1182
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1183
|
+
The function expects an argparse.Namespace from parse_arguments() with a `command`
|
|
1184
|
+
attribute set to one of: "config", "auth", or "service". Delegates to the
|
|
1185
|
+
corresponding handler and returns its exit code. If `command` is unknown,
|
|
1186
|
+
prints an error and returns 1.
|
|
1036
1187
|
"""
|
|
1037
1188
|
if args.command == "config":
|
|
1038
1189
|
return handle_config_command(args)
|
|
@@ -1040,6 +1191,7 @@ def handle_subcommand(args):
|
|
|
1040
1191
|
return handle_auth_command(args)
|
|
1041
1192
|
elif args.command == "service":
|
|
1042
1193
|
return handle_service_command(args)
|
|
1194
|
+
|
|
1043
1195
|
else:
|
|
1044
1196
|
print(f"Unknown command: {args.command}")
|
|
1045
1197
|
return 1
|
|
@@ -1047,21 +1199,25 @@ def handle_subcommand(args):
|
|
|
1047
1199
|
|
|
1048
1200
|
def handle_config_command(args):
|
|
1049
1201
|
"""
|
|
1050
|
-
Dispatch the
|
|
1202
|
+
Dispatch the "config" command group to the selected subcommand handler.
|
|
1051
1203
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1204
|
+
Supported subcommands:
|
|
1205
|
+
- "generate": create or update the sample configuration file at the preferred location.
|
|
1206
|
+
- "check": validate the resolved configuration file (delegates to check_config).
|
|
1207
|
+
- "diagnose": run a sequence of non-destructive diagnostics and print a report (delegates to handle_config_diagnose).
|
|
1054
1208
|
|
|
1055
1209
|
Parameters:
|
|
1056
|
-
args (argparse.Namespace):
|
|
1210
|
+
args (argparse.Namespace): CLI namespace containing `config_command` (one of "generate", "check", "diagnose") and any subcommand-specific options.
|
|
1057
1211
|
|
|
1058
1212
|
Returns:
|
|
1059
|
-
int:
|
|
1213
|
+
int: Exit code (0 on success, 1 on failure or for unknown subcommands).
|
|
1060
1214
|
"""
|
|
1061
1215
|
if args.config_command == "generate":
|
|
1062
1216
|
return 0 if generate_sample_config() else 1
|
|
1063
1217
|
elif args.config_command == "check":
|
|
1064
1218
|
return 0 if check_config(args) else 1
|
|
1219
|
+
elif args.config_command == "diagnose":
|
|
1220
|
+
return handle_config_diagnose(args)
|
|
1065
1221
|
else:
|
|
1066
1222
|
print(f"Unknown config command: {args.config_command}")
|
|
1067
1223
|
return 1
|
|
@@ -1096,24 +1252,97 @@ def handle_auth_command(args):
|
|
|
1096
1252
|
|
|
1097
1253
|
def handle_auth_login(args):
|
|
1098
1254
|
"""
|
|
1099
|
-
Run the
|
|
1255
|
+
Run the Matrix bot login flow and return a CLI-style exit code.
|
|
1256
|
+
|
|
1257
|
+
Performs Matrix bot authentication either interactively (prompts the user) or non-interactively
|
|
1258
|
+
when all three parameters (--homeserver, --username, --password) are provided on the command line.
|
|
1259
|
+
For non-interactive mode, --homeserver and --username must be non-empty strings; --password may be
|
|
1260
|
+
an empty string (some flows will prompt). Supplying some but not all of the three parameters
|
|
1261
|
+
is treated as an error and the function exits with a non-zero status.
|
|
1100
1262
|
|
|
1101
|
-
|
|
1263
|
+
Returns:
|
|
1264
|
+
int: 0 on successful authentication, 1 on failure, cancellation (KeyboardInterrupt), or unexpected errors.
|
|
1102
1265
|
|
|
1103
1266
|
Parameters:
|
|
1104
|
-
args: Parsed
|
|
1267
|
+
args: Parsed CLI namespace; may contain attributes `homeserver`, `username`, and `password`.
|
|
1105
1268
|
"""
|
|
1106
1269
|
import asyncio
|
|
1107
1270
|
|
|
1108
1271
|
from mmrelay.matrix_utils import login_matrix_bot
|
|
1109
1272
|
|
|
1110
|
-
#
|
|
1111
|
-
|
|
1112
|
-
|
|
1273
|
+
# Extract arguments
|
|
1274
|
+
homeserver = getattr(args, "homeserver", None)
|
|
1275
|
+
username = getattr(args, "username", None)
|
|
1276
|
+
password = getattr(args, "password", None)
|
|
1277
|
+
|
|
1278
|
+
# Count provided parameters (empty strings count as provided)
|
|
1279
|
+
provided_params = [p for p in [homeserver, username, password] if p is not None]
|
|
1280
|
+
|
|
1281
|
+
# Determine mode based on parameters provided
|
|
1282
|
+
if len(provided_params) == 3:
|
|
1283
|
+
# All parameters provided - validate required non-empty fields
|
|
1284
|
+
if not _is_valid_non_empty_string(homeserver) or not _is_valid_non_empty_string(
|
|
1285
|
+
username
|
|
1286
|
+
):
|
|
1287
|
+
print(
|
|
1288
|
+
"❌ Error: --homeserver and --username must be non-empty for non-interactive login."
|
|
1289
|
+
)
|
|
1290
|
+
return 1
|
|
1291
|
+
# Password may be empty (flows may prompt)
|
|
1292
|
+
elif len(provided_params) > 0:
|
|
1293
|
+
# Some but not all parameters provided - show error
|
|
1294
|
+
missing_params = []
|
|
1295
|
+
if homeserver is None:
|
|
1296
|
+
missing_params.append("--homeserver")
|
|
1297
|
+
if username is None:
|
|
1298
|
+
missing_params.append("--username")
|
|
1299
|
+
if password is None:
|
|
1300
|
+
missing_params.append("--password")
|
|
1301
|
+
|
|
1302
|
+
error_message = f"""❌ Error: All authentication parameters are required when using command-line options.
|
|
1303
|
+
Missing: {', '.join(missing_params)}
|
|
1304
|
+
|
|
1305
|
+
💡 Options:
|
|
1306
|
+
• For secure interactive authentication: mmrelay auth login
|
|
1307
|
+
• For automated authentication: provide all three parameters
|
|
1308
|
+
|
|
1309
|
+
⚠️ Security Note: Command-line passwords may be visible in process lists and shell history.
|
|
1310
|
+
Interactive mode is recommended for manual use."""
|
|
1311
|
+
print(error_message)
|
|
1312
|
+
return 1
|
|
1313
|
+
else:
|
|
1314
|
+
# No parameters provided - run in interactive mode
|
|
1315
|
+
# Check if E2EE is actually configured before mentioning it
|
|
1316
|
+
# Use silent checking to avoid warnings during initial setup
|
|
1317
|
+
try:
|
|
1318
|
+
from mmrelay.config import check_e2ee_enabled_silently
|
|
1319
|
+
|
|
1320
|
+
e2ee_enabled = check_e2ee_enabled_silently(args)
|
|
1321
|
+
|
|
1322
|
+
if e2ee_enabled:
|
|
1323
|
+
print("Matrix Bot Authentication for E2EE")
|
|
1324
|
+
print("===================================")
|
|
1325
|
+
else:
|
|
1326
|
+
print("\nMatrix Bot Authentication")
|
|
1327
|
+
print("=========================")
|
|
1328
|
+
except (OSError, PermissionError, ImportError, ValueError) as e:
|
|
1329
|
+
# Fallback if silent checking fails due to config file or import issues
|
|
1330
|
+
from mmrelay.log_utils import get_logger
|
|
1331
|
+
|
|
1332
|
+
logger = get_logger("CLI")
|
|
1333
|
+
logger.debug(f"Failed to silently check E2EE status: {e}")
|
|
1334
|
+
print("\nMatrix Bot Authentication")
|
|
1335
|
+
print("=========================")
|
|
1113
1336
|
|
|
1114
1337
|
try:
|
|
1115
|
-
|
|
1116
|
-
|
|
1338
|
+
result = asyncio.run(
|
|
1339
|
+
login_matrix_bot(
|
|
1340
|
+
homeserver=homeserver,
|
|
1341
|
+
username=username,
|
|
1342
|
+
password=password,
|
|
1343
|
+
logout_others=False,
|
|
1344
|
+
)
|
|
1345
|
+
)
|
|
1117
1346
|
return 0 if result else 1
|
|
1118
1347
|
except KeyboardInterrupt:
|
|
1119
1348
|
print("\nAuthentication cancelled by user.")
|
|
@@ -1125,38 +1354,66 @@ def handle_auth_login(args):
|
|
|
1125
1354
|
|
|
1126
1355
|
def handle_auth_status(args):
|
|
1127
1356
|
"""
|
|
1128
|
-
|
|
1357
|
+
Print the Matrix authentication status by locating and reading a credentials.json file.
|
|
1129
1358
|
|
|
1130
|
-
Searches
|
|
1359
|
+
Searches for credentials.json next to each discovered config file (in preference order),
|
|
1360
|
+
then falls back to the application's base directory. If a readable credentials.json is
|
|
1361
|
+
found, prints its path and the homeserver, user_id, and device_id values.
|
|
1131
1362
|
|
|
1132
1363
|
Parameters:
|
|
1133
|
-
args
|
|
1364
|
+
args: argparse.Namespace
|
|
1365
|
+
Parsed CLI arguments (used to locate config file paths).
|
|
1134
1366
|
|
|
1135
1367
|
Returns:
|
|
1136
|
-
int: Exit code — 0 if a
|
|
1368
|
+
int: Exit code — 0 if a valid credentials.json was found and read, 1 otherwise.
|
|
1369
|
+
|
|
1370
|
+
Side effects:
|
|
1371
|
+
Writes human-readable status messages to stdout.
|
|
1137
1372
|
"""
|
|
1138
1373
|
import json
|
|
1139
|
-
import os
|
|
1140
1374
|
|
|
1141
|
-
from mmrelay.config import get_config_paths
|
|
1375
|
+
from mmrelay.config import get_base_dir, get_config_paths
|
|
1142
1376
|
|
|
1143
1377
|
print("Matrix Authentication Status")
|
|
1144
1378
|
print("============================")
|
|
1145
1379
|
|
|
1146
|
-
# Check for credentials.json
|
|
1147
1380
|
config_paths = get_config_paths(args)
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1381
|
+
|
|
1382
|
+
# Developer note: Build a de-duplicated sequence of candidate locations,
|
|
1383
|
+
# preserving preference order: each config-adjacent credentials.json first,
|
|
1384
|
+
# then the standard base-dir fallback.
|
|
1385
|
+
seen = set()
|
|
1386
|
+
candidate_paths = []
|
|
1387
|
+
for p in (
|
|
1388
|
+
os.path.join(os.path.dirname(cp), "credentials.json") for cp in config_paths
|
|
1389
|
+
):
|
|
1390
|
+
if p not in seen:
|
|
1391
|
+
candidate_paths.append(p)
|
|
1392
|
+
seen.add(p)
|
|
1393
|
+
base_candidate = os.path.join(get_base_dir(), "credentials.json")
|
|
1394
|
+
if base_candidate not in seen:
|
|
1395
|
+
candidate_paths.append(base_candidate)
|
|
1396
|
+
|
|
1397
|
+
for credentials_path in candidate_paths:
|
|
1151
1398
|
if os.path.exists(credentials_path):
|
|
1152
1399
|
try:
|
|
1153
|
-
with open(credentials_path, "r") as f:
|
|
1400
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
1154
1401
|
credentials = json.load(f)
|
|
1155
1402
|
|
|
1403
|
+
required = ("homeserver", "access_token", "user_id", "device_id")
|
|
1404
|
+
if not all(
|
|
1405
|
+
isinstance(credentials.get(k), str) and credentials.get(k).strip()
|
|
1406
|
+
for k in required
|
|
1407
|
+
):
|
|
1408
|
+
print(
|
|
1409
|
+
f"❌ Error: credentials.json at {credentials_path} is missing required fields"
|
|
1410
|
+
)
|
|
1411
|
+
print(f"Run '{get_command('auth_login')}' to authenticate")
|
|
1412
|
+
return 1
|
|
1156
1413
|
print(f"✅ Found credentials.json at: {credentials_path}")
|
|
1157
|
-
print(f" Homeserver: {credentials.get('homeserver'
|
|
1158
|
-
print(f" User ID: {credentials.get('user_id'
|
|
1159
|
-
print(f" Device ID: {credentials.get('device_id'
|
|
1414
|
+
print(f" Homeserver: {credentials.get('homeserver')}")
|
|
1415
|
+
print(f" User ID: {credentials.get('user_id')}")
|
|
1416
|
+
print(f" Device ID: {credentials.get('device_id')}")
|
|
1160
1417
|
return 0
|
|
1161
1418
|
except Exception as e:
|
|
1162
1419
|
print(f"❌ Error reading credentials.json: {e}")
|
|
@@ -1169,15 +1426,21 @@ def handle_auth_status(args):
|
|
|
1169
1426
|
|
|
1170
1427
|
def handle_auth_logout(args):
|
|
1171
1428
|
"""
|
|
1172
|
-
Log out the bot
|
|
1429
|
+
Log out the Matrix bot and remove local session artifacts.
|
|
1173
1430
|
|
|
1174
|
-
Prompts for a verification password (unless
|
|
1431
|
+
Prompts for a verification password (unless a non-empty password is provided via args.password),
|
|
1432
|
+
optionally asks for interactive confirmation (skipped if args.yes is True), and attempts to clear
|
|
1433
|
+
local session data (credentials, E2EE store) and invalidate the bot's access token.
|
|
1175
1434
|
|
|
1176
1435
|
Parameters:
|
|
1177
|
-
args (argparse.Namespace): CLI arguments
|
|
1178
|
-
password (str | None): If
|
|
1179
|
-
If
|
|
1180
|
-
yes (bool): If True, skip the
|
|
1436
|
+
args (argparse.Namespace): CLI arguments with the following relevant attributes:
|
|
1437
|
+
password (str | None): If a non-empty string is provided, it will be used as the
|
|
1438
|
+
verification password. If None or an empty string, the function prompts securely.
|
|
1439
|
+
yes (bool): If True, skip the confirmation prompt.
|
|
1440
|
+
|
|
1441
|
+
Returns:
|
|
1442
|
+
int: 0 on successful logout, 1 on failure or if the operation is cancelled (including
|
|
1443
|
+
KeyboardInterrupt).
|
|
1181
1444
|
"""
|
|
1182
1445
|
import asyncio
|
|
1183
1446
|
|
|
@@ -1197,7 +1460,11 @@ def handle_auth_logout(args):
|
|
|
1197
1460
|
# Handle password input
|
|
1198
1461
|
password = getattr(args, "password", None)
|
|
1199
1462
|
|
|
1200
|
-
if
|
|
1463
|
+
if (
|
|
1464
|
+
password is None
|
|
1465
|
+
or password
|
|
1466
|
+
== "" # nosec B105 (user-entered secret; prompting securely via getpass)
|
|
1467
|
+
):
|
|
1201
1468
|
# No --password flag or --password with no value, prompt securely
|
|
1202
1469
|
import getpass
|
|
1203
1470
|
|
|
@@ -1231,10 +1498,16 @@ def handle_auth_logout(args):
|
|
|
1231
1498
|
|
|
1232
1499
|
def handle_service_command(args):
|
|
1233
1500
|
"""
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
Currently supports the "install" subcommand which
|
|
1237
|
-
Returns 0 on
|
|
1501
|
+
Dispatch service-related subcommands.
|
|
1502
|
+
|
|
1503
|
+
Currently supports the "install" subcommand which imports and runs mmrelay.setup_utils.install_service().
|
|
1504
|
+
Returns 0 on successful installation, 1 on failure or for unknown subcommands.
|
|
1505
|
+
|
|
1506
|
+
Parameters:
|
|
1507
|
+
args: argparse.Namespace with a `service_command` attribute indicating the requested action.
|
|
1508
|
+
|
|
1509
|
+
Returns:
|
|
1510
|
+
int: Exit code (0 on success, 1 on error).
|
|
1238
1511
|
"""
|
|
1239
1512
|
if args.service_command == "install":
|
|
1240
1513
|
try:
|
|
@@ -1249,6 +1522,259 @@ def handle_service_command(args):
|
|
|
1249
1522
|
return 1
|
|
1250
1523
|
|
|
1251
1524
|
|
|
1525
|
+
def _diagnose_config_paths(args):
|
|
1526
|
+
"""
|
|
1527
|
+
Print a diagnostic summary of resolved configuration file search paths and their directory accessibility.
|
|
1528
|
+
|
|
1529
|
+
Uses get_config_paths(args) to compute the ordered list of candidate config file locations, then prints each path with a concise directory status icon:
|
|
1530
|
+
- ✅ directory exists and is writable
|
|
1531
|
+
- ⚠️ directory exists but is not writable
|
|
1532
|
+
- ❌ directory does not exist
|
|
1533
|
+
|
|
1534
|
+
Parameters:
|
|
1535
|
+
args (argparse.Namespace): Parsed CLI arguments used to determine the config search order.
|
|
1536
|
+
"""
|
|
1537
|
+
print("1. Testing configuration paths...")
|
|
1538
|
+
from mmrelay.config import get_config_paths
|
|
1539
|
+
|
|
1540
|
+
paths = get_config_paths(args)
|
|
1541
|
+
print(f" Config search paths: {len(paths)} locations")
|
|
1542
|
+
for i, path in enumerate(paths, 1):
|
|
1543
|
+
dir_path = os.path.dirname(path)
|
|
1544
|
+
dir_exists = os.path.exists(dir_path)
|
|
1545
|
+
dir_writable = os.access(dir_path, os.W_OK) if dir_exists else False
|
|
1546
|
+
status = "✅" if dir_exists and dir_writable else "⚠️" if dir_exists else "❌"
|
|
1547
|
+
print(f" {i}. {path} {status}")
|
|
1548
|
+
print()
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _diagnose_sample_config_accessibility():
|
|
1552
|
+
"""
|
|
1553
|
+
Check availability of the bundled sample configuration and print a short diagnostic.
|
|
1554
|
+
|
|
1555
|
+
Performs two non-destructive checks and prints human-readable results:
|
|
1556
|
+
1) Verifies whether the sample config file exists at the path returned by mmrelay.tools.get_sample_config_path().
|
|
1557
|
+
2) Attempts to read the embedded resource "sample_config.yaml" from the mmrelay.tools package via importlib.resources and reports success and the content length.
|
|
1558
|
+
|
|
1559
|
+
Returns:
|
|
1560
|
+
bool: True if a filesystem sample config exists at the resolved path; False otherwise.
|
|
1561
|
+
"""
|
|
1562
|
+
print("2. Testing sample config accessibility...")
|
|
1563
|
+
from mmrelay.tools import get_sample_config_path
|
|
1564
|
+
|
|
1565
|
+
sample_path = get_sample_config_path()
|
|
1566
|
+
sample_exists = os.path.exists(sample_path)
|
|
1567
|
+
print(f" Sample config path: {sample_path}")
|
|
1568
|
+
print(f" Sample config exists: {'✅' if sample_exists else '❌'}")
|
|
1569
|
+
|
|
1570
|
+
# Test importlib.resources fallback
|
|
1571
|
+
try:
|
|
1572
|
+
import importlib.resources
|
|
1573
|
+
|
|
1574
|
+
content = (
|
|
1575
|
+
importlib.resources.files("mmrelay.tools")
|
|
1576
|
+
.joinpath("sample_config.yaml")
|
|
1577
|
+
.read_text()
|
|
1578
|
+
)
|
|
1579
|
+
print(f" importlib.resources fallback: ✅ ({len(content)} chars)")
|
|
1580
|
+
except (FileNotFoundError, ImportError, OSError) as e:
|
|
1581
|
+
print(f" importlib.resources fallback: ❌ ({e})")
|
|
1582
|
+
print()
|
|
1583
|
+
|
|
1584
|
+
return sample_exists
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _diagnose_platform_specific(args):
|
|
1588
|
+
"""
|
|
1589
|
+
Run platform-specific diagnostic checks.
|
|
1590
|
+
|
|
1591
|
+
On Windows, imports and runs Windows-specific requirement checks and a configuration-generation
|
|
1592
|
+
test from mmrelay.windows_utils, printing per-component results and any warnings. On non-Windows
|
|
1593
|
+
platforms this reports that platform-specific tests are not required.
|
|
1594
|
+
|
|
1595
|
+
Parameters:
|
|
1596
|
+
args (argparse.Namespace): Parsed CLI arguments; passed through to the Windows
|
|
1597
|
+
config-generation test when running on Windows.
|
|
1598
|
+
|
|
1599
|
+
Returns:
|
|
1600
|
+
bool: True if the platform is Windows (Windows checks were executed), False otherwise.
|
|
1601
|
+
"""
|
|
1602
|
+
print("3. Platform-specific diagnostics...")
|
|
1603
|
+
import sys
|
|
1604
|
+
|
|
1605
|
+
from mmrelay.constants.app import WINDOWS_PLATFORM
|
|
1606
|
+
|
|
1607
|
+
on_windows = sys.platform == WINDOWS_PLATFORM
|
|
1608
|
+
print(f" Platform: {sys.platform}")
|
|
1609
|
+
print(f" Windows: {'Yes' if on_windows else 'No'}")
|
|
1610
|
+
|
|
1611
|
+
if on_windows:
|
|
1612
|
+
try:
|
|
1613
|
+
from mmrelay.windows_utils import (
|
|
1614
|
+
check_windows_requirements,
|
|
1615
|
+
test_config_generation_windows,
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
# Check Windows requirements
|
|
1619
|
+
warnings = check_windows_requirements()
|
|
1620
|
+
if warnings:
|
|
1621
|
+
print(" Windows warnings: ⚠️")
|
|
1622
|
+
for line in warnings.split("\n"):
|
|
1623
|
+
if line.strip():
|
|
1624
|
+
print(f" {line}")
|
|
1625
|
+
else:
|
|
1626
|
+
print(" Windows compatibility: ✅")
|
|
1627
|
+
|
|
1628
|
+
# Run Windows-specific tests
|
|
1629
|
+
print("\n Windows config generation test:")
|
|
1630
|
+
results = test_config_generation_windows(args)
|
|
1631
|
+
|
|
1632
|
+
for component, result in results.items():
|
|
1633
|
+
if component == "overall_status":
|
|
1634
|
+
continue
|
|
1635
|
+
if isinstance(result, dict):
|
|
1636
|
+
status_icon = (
|
|
1637
|
+
"✅"
|
|
1638
|
+
if result["status"] == "ok"
|
|
1639
|
+
else "❌" if result["status"] == "error" else "⚠️"
|
|
1640
|
+
)
|
|
1641
|
+
print(f" {component}: {status_icon}")
|
|
1642
|
+
|
|
1643
|
+
overall = results.get("overall_status", "unknown")
|
|
1644
|
+
print(
|
|
1645
|
+
f" Overall Windows status: {'✅' if overall == 'ok' else '⚠️' if overall == 'partial' else '❌'}"
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
except ImportError:
|
|
1649
|
+
print(" Windows utilities: ❌ (not available)")
|
|
1650
|
+
else:
|
|
1651
|
+
print(" Platform-specific tests: ✅ (Unix-like system)")
|
|
1652
|
+
|
|
1653
|
+
print()
|
|
1654
|
+
return on_windows
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def _get_minimal_config_template():
|
|
1658
|
+
"""
|
|
1659
|
+
Return a minimal MMRelay YAML configuration template used as a fallback when the packaged sample_config.yaml cannot be located.
|
|
1660
|
+
|
|
1661
|
+
The template contains a small, functional configuration (matrix connection hints, a serial meshtastic connection, one room entry, and basic logging) that users can edit to create a working config file.
|
|
1662
|
+
|
|
1663
|
+
Returns:
|
|
1664
|
+
str: A YAML-formatted minimal configuration template.
|
|
1665
|
+
"""
|
|
1666
|
+
return """# MMRelay Configuration File
|
|
1667
|
+
# This is a minimal template created when the full sample config was unavailable
|
|
1668
|
+
# For complete configuration options, visit:
|
|
1669
|
+
# https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki
|
|
1670
|
+
|
|
1671
|
+
matrix:
|
|
1672
|
+
homeserver: https://matrix.example.org
|
|
1673
|
+
# Use 'mmrelay auth login' to set up authentication
|
|
1674
|
+
# access_token: your_access_token_here
|
|
1675
|
+
# bot_user_id: '@your_bot:matrix.example.org'
|
|
1676
|
+
|
|
1677
|
+
meshtastic:
|
|
1678
|
+
connection_type: serial
|
|
1679
|
+
serial_port: /dev/ttyUSB0 # Windows: COM3, macOS: /dev/cu.usbserial-*
|
|
1680
|
+
# host: meshtastic.local # For network connection
|
|
1681
|
+
# ble_address: "your_device_address" # For BLE connection
|
|
1682
|
+
|
|
1683
|
+
matrix_rooms:
|
|
1684
|
+
- id: '#your-room:matrix.example.org'
|
|
1685
|
+
meshtastic_channel: 0
|
|
1686
|
+
|
|
1687
|
+
logging:
|
|
1688
|
+
level: info
|
|
1689
|
+
|
|
1690
|
+
# Uncomment and configure as needed:
|
|
1691
|
+
# database:
|
|
1692
|
+
# msg_map:
|
|
1693
|
+
# msgs_to_keep: 100
|
|
1694
|
+
|
|
1695
|
+
# plugins:
|
|
1696
|
+
# ping:
|
|
1697
|
+
# active: true
|
|
1698
|
+
# weather:
|
|
1699
|
+
# active: true
|
|
1700
|
+
# units: metric
|
|
1701
|
+
"""
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _diagnose_minimal_config_template():
|
|
1705
|
+
"""
|
|
1706
|
+
Validate the built-in minimal YAML configuration template and print a concise pass/fail status.
|
|
1707
|
+
|
|
1708
|
+
Attempts to parse the string returned by _get_minimal_config_template() using yaml.safe_load. Prints a single-line result showing a ✅ with the template character length when the template is valid YAML, or a ❌ with the YAML parsing error when invalid. This is a non-destructive diagnostic helper that prints output and does not return a value.
|
|
1709
|
+
"""
|
|
1710
|
+
print("4. Testing minimal config template fallback...")
|
|
1711
|
+
try:
|
|
1712
|
+
template = _get_minimal_config_template()
|
|
1713
|
+
yaml.safe_load(template)
|
|
1714
|
+
print(f" Minimal template: ✅ ({len(template)} chars, valid YAML)")
|
|
1715
|
+
except yaml.YAMLError as e:
|
|
1716
|
+
print(f" Minimal template: ❌ ({e})")
|
|
1717
|
+
|
|
1718
|
+
print()
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def handle_config_diagnose(args):
|
|
1722
|
+
"""
|
|
1723
|
+
Run non-destructive diagnostics for the MMRelay configuration subsystem and print a human-readable report.
|
|
1724
|
+
|
|
1725
|
+
Performs four checks: resolves candidate config paths and reports directory accessibility; verifies packaged sample config availability (filesystem and importlib.resources fallback); runs platform-specific diagnostics (Windows-focused where applicable); and validates the built-in minimal YAML template. Prints findings and suggested next steps to stdout/stderr.
|
|
1726
|
+
|
|
1727
|
+
Parameters:
|
|
1728
|
+
args (argparse.Namespace): Parsed CLI arguments used to resolve config search paths and to control platform-specific checks.
|
|
1729
|
+
|
|
1730
|
+
Returns:
|
|
1731
|
+
int: Exit code (0 on success, 1 on failure).
|
|
1732
|
+
"""
|
|
1733
|
+
print("MMRelay Configuration System Diagnostics")
|
|
1734
|
+
print("=" * 40)
|
|
1735
|
+
print()
|
|
1736
|
+
|
|
1737
|
+
try:
|
|
1738
|
+
# Test 1: Basic config path resolution
|
|
1739
|
+
_diagnose_config_paths(args)
|
|
1740
|
+
|
|
1741
|
+
# Test 2: Sample config accessibility
|
|
1742
|
+
sample_exists = _diagnose_sample_config_accessibility()
|
|
1743
|
+
|
|
1744
|
+
# Test 3: Platform-specific diagnostics
|
|
1745
|
+
on_windows = _diagnose_platform_specific(args)
|
|
1746
|
+
|
|
1747
|
+
# Test 4: Minimal config template
|
|
1748
|
+
_diagnose_minimal_config_template()
|
|
1749
|
+
|
|
1750
|
+
print("=" * 40)
|
|
1751
|
+
print("Diagnostics complete!")
|
|
1752
|
+
|
|
1753
|
+
# Provide guidance based on results
|
|
1754
|
+
if on_windows and not sample_exists:
|
|
1755
|
+
print("\n💡 Windows Troubleshooting Tips:")
|
|
1756
|
+
print(" • Try: pip install --upgrade --force-reinstall mmrelay")
|
|
1757
|
+
print(" • Use: python -m mmrelay config generate")
|
|
1758
|
+
print(" • Check antivirus software for quarantined files")
|
|
1759
|
+
|
|
1760
|
+
return 0
|
|
1761
|
+
|
|
1762
|
+
except Exception as e:
|
|
1763
|
+
print(f"❌ Diagnostics failed: {e}", file=sys.stderr)
|
|
1764
|
+
|
|
1765
|
+
# Provide platform-specific guidance
|
|
1766
|
+
try:
|
|
1767
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1768
|
+
|
|
1769
|
+
if is_windows():
|
|
1770
|
+
error_msg = get_windows_error_message(e)
|
|
1771
|
+
print(f"\nWindows-specific guidance: {error_msg}", file=sys.stderr)
|
|
1772
|
+
except ImportError:
|
|
1773
|
+
pass
|
|
1774
|
+
|
|
1775
|
+
return 1
|
|
1776
|
+
|
|
1777
|
+
|
|
1252
1778
|
if __name__ == "__main__":
|
|
1253
1779
|
import sys
|
|
1254
1780
|
|
|
@@ -1311,7 +1837,8 @@ def generate_sample_config():
|
|
|
1311
1837
|
If an existing config file is found in any candidate path (from get_config_paths()), this function aborts and prints its location. Otherwise it creates a sample config at the first candidate path. Sources tried, in order, are:
|
|
1312
1838
|
- the path returned by get_sample_config_path(),
|
|
1313
1839
|
- the packaged resource mmrelay.tools:sample_config.yaml via importlib.resources,
|
|
1314
|
-
- a set of fallback filesystem locations relative to the package and current working directory
|
|
1840
|
+
- a set of fallback filesystem locations relative to the package and current working directory,
|
|
1841
|
+
- a minimal configuration template as a last resort.
|
|
1315
1842
|
|
|
1316
1843
|
On success the sample is written to disk and (on Unix-like systems) secure file permissions are applied (owner read/write, 0o600). Returns True when a sample config is successfully generated and False on any error or if a config already exists.
|
|
1317
1844
|
"""
|
|
@@ -1356,7 +1883,17 @@ def generate_sample_config():
|
|
|
1356
1883
|
)
|
|
1357
1884
|
return True
|
|
1358
1885
|
except (IOError, OSError) as e:
|
|
1359
|
-
|
|
1886
|
+
# Provide Windows-specific error guidance if available
|
|
1887
|
+
try:
|
|
1888
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1889
|
+
|
|
1890
|
+
if is_windows():
|
|
1891
|
+
error_msg = get_windows_error_message(e)
|
|
1892
|
+
print(f"Error copying sample config file: {error_msg}")
|
|
1893
|
+
else:
|
|
1894
|
+
print(f"Error copying sample config file: {e}")
|
|
1895
|
+
except ImportError:
|
|
1896
|
+
print(f"Error copying sample config file: {e}")
|
|
1360
1897
|
return False
|
|
1361
1898
|
|
|
1362
1899
|
# If the helper function failed, try using importlib.resources directly
|
|
@@ -1369,7 +1906,7 @@ def generate_sample_config():
|
|
|
1369
1906
|
)
|
|
1370
1907
|
|
|
1371
1908
|
# Write the sample config to the target path
|
|
1372
|
-
with open(target_path, "w") as f:
|
|
1909
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
1373
1910
|
f.write(sample_config_content)
|
|
1374
1911
|
|
|
1375
1912
|
# Set secure permissions on Unix systems (600 - owner read/write)
|
|
@@ -1381,7 +1918,17 @@ def generate_sample_config():
|
|
|
1381
1918
|
)
|
|
1382
1919
|
return True
|
|
1383
1920
|
except (FileNotFoundError, ImportError, OSError) as e:
|
|
1384
|
-
print(f"Error accessing sample_config.yaml: {e}")
|
|
1921
|
+
print(f"Error accessing sample_config.yaml via importlib.resources: {e}")
|
|
1922
|
+
|
|
1923
|
+
# Provide Windows-specific guidance if needed
|
|
1924
|
+
try:
|
|
1925
|
+
from mmrelay.windows_utils import is_windows
|
|
1926
|
+
|
|
1927
|
+
if is_windows():
|
|
1928
|
+
print("This may be due to Windows installer packaging differences.")
|
|
1929
|
+
print("Trying alternative methods...")
|
|
1930
|
+
except ImportError:
|
|
1931
|
+
pass
|
|
1385
1932
|
|
|
1386
1933
|
# Fallback to traditional file paths if importlib.resources fails
|
|
1387
1934
|
# First, check in the package directory
|
|
@@ -1409,8 +1956,61 @@ def generate_sample_config():
|
|
|
1409
1956
|
)
|
|
1410
1957
|
return True
|
|
1411
1958
|
except (IOError, OSError) as e:
|
|
1412
|
-
|
|
1959
|
+
# Provide Windows-specific error guidance if available
|
|
1960
|
+
try:
|
|
1961
|
+
from mmrelay.windows_utils import (
|
|
1962
|
+
get_windows_error_message,
|
|
1963
|
+
is_windows,
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
if is_windows():
|
|
1967
|
+
error_msg = get_windows_error_message(e)
|
|
1968
|
+
print(
|
|
1969
|
+
f"Error copying sample config file from {path}: {error_msg}"
|
|
1970
|
+
)
|
|
1971
|
+
else:
|
|
1972
|
+
print(f"Error copying sample config file from {path}: {e}")
|
|
1973
|
+
except ImportError:
|
|
1974
|
+
print(f"Error copying sample config file from {path}: {e}")
|
|
1413
1975
|
return False
|
|
1414
1976
|
|
|
1415
|
-
print("Error: Could not find sample_config.yaml")
|
|
1977
|
+
print("Error: Could not find sample_config.yaml in any location")
|
|
1978
|
+
|
|
1979
|
+
# Last resort: create a minimal config template
|
|
1980
|
+
print("\nAttempting to create minimal config template...")
|
|
1981
|
+
try:
|
|
1982
|
+
minimal_config = _get_minimal_config_template()
|
|
1983
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
1984
|
+
f.write(minimal_config)
|
|
1985
|
+
|
|
1986
|
+
# Set secure permissions on Unix systems
|
|
1987
|
+
set_secure_file_permissions(target_path)
|
|
1988
|
+
|
|
1989
|
+
print(f"Created minimal config template at: {target_path}")
|
|
1990
|
+
print(
|
|
1991
|
+
"\n⚠️ This is a minimal template. Please refer to documentation for full configuration options."
|
|
1992
|
+
)
|
|
1993
|
+
print("Visit: https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki")
|
|
1994
|
+
return True
|
|
1995
|
+
|
|
1996
|
+
except (IOError, OSError) as e:
|
|
1997
|
+
print(f"Failed to create minimal config template: {e}")
|
|
1998
|
+
|
|
1999
|
+
# Provide Windows-specific troubleshooting guidance
|
|
2000
|
+
try:
|
|
2001
|
+
from mmrelay.windows_utils import is_windows
|
|
2002
|
+
|
|
2003
|
+
if is_windows():
|
|
2004
|
+
print("\nWindows Troubleshooting:")
|
|
2005
|
+
print("1. Check if MMRelay was installed correctly")
|
|
2006
|
+
print("2. Try reinstalling with: pipx install --force mmrelay")
|
|
2007
|
+
print(
|
|
2008
|
+
"3. Use alternative entry point: python -m mmrelay config generate"
|
|
2009
|
+
)
|
|
2010
|
+
print("4. Check antivirus software - it may have quarantined files")
|
|
2011
|
+
print("5. Run diagnostics: python -m mmrelay config diagnose")
|
|
2012
|
+
print("6. Manually create config file using documentation")
|
|
2013
|
+
except ImportError:
|
|
2014
|
+
pass
|
|
2015
|
+
|
|
1416
2016
|
return False
|