mmrelay 1.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/cli_utils.py ADDED
@@ -0,0 +1,746 @@
1
+ """
2
+ CLI utilities and command registry.
3
+
4
+ This module provides a centralized registry of all CLI commands to ensure
5
+ consistency across error messages, help text, and documentation. It's separate
6
+ from cli.py to avoid circular dependencies when other modules need to reference
7
+ CLI commands.
8
+
9
+ It also contains CLI-specific functions that need to interact with users
10
+ via print statements (as opposed to library functions that should only log).
11
+
12
+ Usage:
13
+ from mmrelay.cli_utils import get_command, suggest_command, logout_matrix_bot
14
+
15
+ # Get a command string
16
+ cmd = get_command('generate_config') # Returns "mmrelay config generate"
17
+
18
+ # Generate suggestion messages
19
+ msg = suggest_command('generate_config', 'to create a sample configuration')
20
+
21
+ # CLI functions (can use print statements)
22
+ result = await logout_matrix_bot(password="user_password")
23
+ """
24
+
25
+ import asyncio
26
+ import logging
27
+ import os
28
+ import ssl
29
+
30
+ try:
31
+ import certifi
32
+ except ImportError:
33
+ certifi = None
34
+
35
+ # Import Matrix-related modules for logout functionality
36
+ try:
37
+ from nio import AsyncClient
38
+ from nio.exceptions import (
39
+ LocalProtocolError,
40
+ LocalTransportError,
41
+ RemoteProtocolError,
42
+ RemoteTransportError,
43
+ )
44
+ from nio.responses import LoginError, LogoutError
45
+
46
+ # Create aliases for backward compatibility
47
+ NioLoginError = LoginError
48
+ NioLogoutError = LogoutError
49
+ NioLocalTransportError = LocalTransportError
50
+ NioRemoteTransportError = RemoteTransportError
51
+ NioLocalProtocolError = LocalProtocolError
52
+ NioRemoteProtocolError = RemoteProtocolError
53
+ except ImportError:
54
+ # Handle case where matrix-nio is not installed
55
+ AsyncClient = None
56
+ LoginError = Exception
57
+ LogoutError = Exception
58
+ LocalTransportError = Exception
59
+ RemoteTransportError = Exception
60
+ LocalProtocolError = Exception
61
+ RemoteProtocolError = Exception
62
+ # Create aliases for backward compatibility
63
+ NioLoginError = Exception
64
+ NioLogoutError = Exception
65
+ NioLocalTransportError = Exception
66
+ NioRemoteTransportError = Exception
67
+ NioLocalProtocolError = Exception
68
+ NioRemoteProtocolError = Exception
69
+
70
+ # Import mmrelay modules - avoid circular imports by importing inside functions
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # Command registry - single source of truth for CLI command syntax
75
+ CLI_COMMANDS = {
76
+ # Config commands
77
+ "generate_config": "mmrelay config generate",
78
+ "check_config": "mmrelay config check",
79
+ # Auth commands
80
+ "auth_login": "mmrelay auth login",
81
+ "auth_status": "mmrelay auth status",
82
+ # Service commands
83
+ "service_install": "mmrelay service install",
84
+ # Main commands
85
+ "start_relay": "mmrelay",
86
+ "show_version": "mmrelay --version",
87
+ "show_help": "mmrelay --help",
88
+ }
89
+
90
+ # Deprecation mappings - maps old flags to new command keys
91
+ DEPRECATED_COMMANDS = {
92
+ "--generate-config": "generate_config",
93
+ "--check-config": "check_config",
94
+ "--install-service": "service_install",
95
+ "--auth": "auth_login",
96
+ }
97
+
98
+
99
+ def get_command(command_key):
100
+ """Get the current command syntax for a given command key.
101
+
102
+ Args:
103
+ command_key (str): The command key (e.g., 'generate_config')
104
+
105
+ Returns:
106
+ str: The current command syntax (e.g., 'mmrelay config generate')
107
+
108
+ Raises:
109
+ KeyError: If the command key is not found in the registry
110
+ """
111
+ if command_key not in CLI_COMMANDS:
112
+ raise KeyError(f"Unknown CLI command key: {command_key}")
113
+ return CLI_COMMANDS[command_key]
114
+
115
+
116
+ def get_deprecation_warning(old_flag):
117
+ """
118
+ Return a user-facing deprecation warning for a deprecated CLI flag.
119
+
120
+ Looks up a replacement command for the given deprecated flag in DEPRECATED_COMMANDS.
121
+ If a replacement exists, the returned message suggests the full new command (resolved
122
+ via get_command). Otherwise it returns a generic guidance message pointing the user
123
+ to `mmrelay --help`.
124
+
125
+ Parameters:
126
+ old_flag (str): Deprecated flag (e.g., '--generate-config').
127
+
128
+ Returns:
129
+ str: Formatted deprecation warning message.
130
+ """
131
+ new_command_key = DEPRECATED_COMMANDS.get(old_flag)
132
+ if new_command_key:
133
+ new_command = get_command(new_command_key)
134
+ return f"Warning: {old_flag} is deprecated. Use '{new_command}' instead."
135
+ return f"Warning: {old_flag} is deprecated. Run 'mmrelay --help' to see the current commands."
136
+
137
+
138
+ def suggest_command(command_key, purpose):
139
+ """
140
+ Return a concise suggestion message that tells the user which CLI command to run.
141
+
142
+ Parameters:
143
+ command_key (str): Key used to look up the full CLI command in the registry.
144
+ purpose (str): Short phrase describing why to run the command (should start with "to", e.g. "to validate your configuration").
145
+
146
+ Returns:
147
+ str: Formatted suggestion like "Run '<command>' {purpose}."
148
+ """
149
+ command = get_command(command_key)
150
+ return f"Run '{command}' {purpose}."
151
+
152
+
153
+ def require_command(command_key, purpose):
154
+ """
155
+ Return a user-facing requirement message that instructs running a registered CLI command.
156
+
157
+ Parameters:
158
+ command_key (str): Key used to look up the command in the CLI registry.
159
+ purpose (str): Short purpose phrase (typically begins with "to"), e.g. "to generate a sample configuration file".
160
+
161
+ Returns:
162
+ str: Formatted message like "Please run '<full command>' {purpose}."
163
+
164
+ Raises:
165
+ KeyError: If `command_key` is not found in the command registry.
166
+ """
167
+ command = get_command(command_key)
168
+ return f"Please run '{command}' {purpose}."
169
+
170
+
171
+ def retry_command(command_key, context=""):
172
+ """
173
+ Return a user-facing retry message instructing the user to run the given CLI command again.
174
+
175
+ Parameters:
176
+ command_key (str): Key from CLI_COMMANDS that identifies the command to show.
177
+ context (str): Optional trailing context to append to the message (e.g., "after fixing X").
178
+
179
+ Returns:
180
+ str: Formatted message, either "Try running '<command>' again." or "Try running '<command>' again {context}."
181
+ """
182
+ command = get_command(command_key)
183
+ if context:
184
+ return f"Try running '{command}' again {context}."
185
+ else:
186
+ return f"Try running '{command}' again."
187
+
188
+
189
+ def validate_command(command_key, purpose):
190
+ """
191
+ Return a user-facing validation message that references a registered CLI command.
192
+
193
+ command_key should be a key from the module's command registry (e.g. "check_config"); purpose is a short phrase describing the validation action (e.g. "to validate your configuration"). Returns a string like: "Use '<full-command>' {purpose}."
194
+ """
195
+ command = get_command(command_key)
196
+ return f"Use '{command}' {purpose}."
197
+
198
+
199
+ # Common message templates for frequently used commands
200
+ def msg_suggest_generate_config():
201
+ """
202
+ Return a standardized user-facing suggestion to generate a sample configuration file.
203
+
204
+ This message references the configured "generate_config" CLI command and is suitable for prompts and help text.
205
+
206
+ Returns:
207
+ str: A sentence instructing the user to run the generate-config command to generate a sample configuration file (e.g., "Run 'mmrelay config generate' to generate a sample configuration file.").
208
+ """
209
+ return suggest_command("generate_config", "to generate a sample configuration file")
210
+
211
+
212
+ def msg_suggest_check_config():
213
+ """
214
+ Return a standardized suggestion prompting the user to validate their configuration.
215
+
216
+ This helper builds the user-visible message that tells users how to validate their config (e.g. by running the configured "check_config" CLI command).
217
+
218
+ Returns:
219
+ str: A full sentence suggesting the user run the config validation command.
220
+ """
221
+ return validate_command("check_config", "to validate your configuration")
222
+
223
+
224
+ def msg_require_auth_login():
225
+ """
226
+ Return a standard instruction asking the user to run the authentication command.
227
+
228
+ This produces a formatted message that tells the user to run the configured "auth_login" CLI command
229
+ to set up credentials.json or to add a Matrix section to config.yaml.
230
+
231
+ Returns:
232
+ str: A user-facing instruction string.
233
+ """
234
+ return require_command(
235
+ "auth_login", "to set up credentials.json, or add matrix section to config.yaml"
236
+ )
237
+
238
+
239
+ def msg_retry_auth_login():
240
+ """Standard message suggesting auth retry."""
241
+ return retry_command("auth_login")
242
+
243
+
244
+ def msg_run_auth_login():
245
+ """
246
+ Return a user-facing message that instructs running the auth login command to (re)generate credentials.
247
+
248
+ The message prompts the user to run the authentication/login command again so new credentials (including a device_id) are created.
249
+
250
+ Returns:
251
+ str: Formatted instruction string for running the auth login command.
252
+ """
253
+ return msg_regenerate_credentials()
254
+
255
+
256
+ def msg_for_e2ee_support():
257
+ """
258
+ Return a user-facing instruction to run the authentication command required for E2EE support.
259
+
260
+ Returns:
261
+ str: A formatted message instructing the user to run the configured `auth_login` CLI command to enable end-to-end encryption (E2EE) support.
262
+ """
263
+ return f"For E2EE support: run '{get_command('auth_login')}'"
264
+
265
+
266
+ def msg_setup_auth():
267
+ """
268
+ Return a standard instruction directing the user to run the authentication setup command.
269
+
270
+ The message is formatted as "Setup: <command>", where <command> is the current CLI syntax for the "auth_login" command resolved from the command registry.
271
+
272
+ Returns:
273
+ str: Formatted setup instruction pointing to the auth login CLI command.
274
+ """
275
+ return f"Setup: {get_command('auth_login')}"
276
+
277
+
278
+ def msg_or_run_auth_login():
279
+ """
280
+ Return a short suggestion offering the `auth_login` command as an alternative to setup.
281
+
282
+ This function formats and returns a user-facing message that tells the caller to
283
+ run the configured `auth_login` CLI command to create or set up credentials.json.
284
+
285
+ Returns:
286
+ str: A message of the form "or run '<command>' to set up credentials.json".
287
+ """
288
+ return f"or run '{get_command('auth_login')}' to set up credentials.json"
289
+
290
+
291
+ def msg_setup_authentication():
292
+ """Standard message for authentication setup."""
293
+ return f"Setup authentication: {get_command('auth_login')}"
294
+
295
+
296
+ def msg_regenerate_credentials():
297
+ """
298
+ Return a standardized instruction prompting the user to re-run the authentication command to regenerate credentials that include a `device_id`.
299
+
300
+ Returns:
301
+ str: Message instructing the user to run the auth login command again to produce new credentials containing a `device_id`.
302
+ """
303
+ return f"Please run '{get_command('auth_login')}' again to generate new credentials that include a device_id."
304
+
305
+
306
+ # Helper functions moved from matrix_utils to break circular dependency
307
+
308
+
309
+ def _create_ssl_context():
310
+ """
311
+ Create an SSLContext for Matrix client connections, preferring certifi's CA bundle when available.
312
+
313
+ Returns:
314
+ ssl.SSLContext | None: An SSLContext configured with certifi's CA file if certifi is present, otherwise the system default SSLContext. Returns None only if context creation fails.
315
+ """
316
+ try:
317
+ if certifi:
318
+ return ssl.create_default_context(cafile=certifi.where())
319
+ else:
320
+ return ssl.create_default_context()
321
+ except Exception as e:
322
+ logger.warning(
323
+ f"Failed to create certifi-backed SSL context, falling back to system default: {e}"
324
+ )
325
+ try:
326
+ return ssl.create_default_context()
327
+ except Exception as fallback_e:
328
+ logger.error(f"Failed to create system default SSL context: {fallback_e}")
329
+ return None
330
+
331
+
332
+ def _cleanup_local_session_data():
333
+ """
334
+ Remove local Matrix session artifacts: credentials.json and any E2EE store directories.
335
+
336
+ This cleans up the on-disk session state used by the Matrix client. It removes:
337
+ - the credentials file at <base_dir>/credentials.json (if present), and
338
+ - E2EE store directories: the default store dir returned by get_e2ee_store_dir()
339
+ plus any user-configured overrides found in the loaded config under
340
+ matrix.e2ee.store_path or matrix.encryption.store_path.
341
+
342
+ Returns:
343
+ bool: True if all targeted files/directories were removed successfully;
344
+ False if any removal failed (for example due to permissions). The
345
+ function makes a best-effort attempt and will still try all removals
346
+ even if some fail.
347
+ """
348
+ import shutil
349
+
350
+ from mmrelay.config import get_base_dir, get_e2ee_store_dir
351
+
352
+ logger.info("Clearing local session data...")
353
+ success = True
354
+
355
+ # Remove credentials.json
356
+ config_dir = get_base_dir()
357
+ credentials_path = os.path.join(config_dir, "credentials.json")
358
+
359
+ if os.path.exists(credentials_path):
360
+ try:
361
+ os.remove(credentials_path)
362
+ logger.info(f"Removed credentials file: {credentials_path}")
363
+ except (OSError, PermissionError) as e:
364
+ logger.error(f"Failed to remove credentials file: {e}")
365
+ success = False
366
+ else:
367
+ logger.info("No credentials file found to remove")
368
+
369
+ # Clear E2EE store directory (default and any configured override)
370
+ candidate_store_paths = {get_e2ee_store_dir()}
371
+ try:
372
+ from mmrelay.config import load_config
373
+
374
+ cfg = load_config(args=None) or {}
375
+ matrix_cfg = cfg.get("matrix", {})
376
+ for section in ("e2ee", "encryption"):
377
+ override = os.path.expanduser(
378
+ matrix_cfg.get(section, {}).get("store_path", "")
379
+ )
380
+ if override:
381
+ candidate_store_paths.add(override)
382
+ except Exception as e:
383
+ logger.debug(
384
+ f"Could not resolve configured E2EE store path: {type(e).__name__}"
385
+ )
386
+
387
+ any_store_found = False
388
+ for store_path in sorted(candidate_store_paths):
389
+ if os.path.exists(store_path):
390
+ any_store_found = True
391
+ try:
392
+ shutil.rmtree(store_path)
393
+ logger.info(f"Removed E2EE store directory: {store_path}")
394
+ except (OSError, PermissionError) as e:
395
+ logger.error(
396
+ f"Failed to remove E2EE store directory '{store_path}': {e}"
397
+ )
398
+ success = False
399
+ if not any_store_found:
400
+ logger.info("No E2EE store directory found to remove")
401
+
402
+ if success:
403
+ logger.info("✅ Logout completed successfully!")
404
+ logger.info("All Matrix sessions and local data have been cleared.")
405
+ logger.info("Run 'mmrelay auth login' to authenticate again.")
406
+ else:
407
+ logger.warning("Logout completed with some errors.")
408
+ logger.warning("Some files may not have been removed due to permission issues.")
409
+
410
+ return success
411
+
412
+
413
+ # CLI-specific functions (can use print statements for user interaction)
414
+
415
+
416
+ def _handle_matrix_error(exception: Exception, context: str, log_level: str = "error"):
417
+ """
418
+ Classify a Matrix-related exception and emit user-facing and logged messages.
419
+
420
+ Determines whether the provided exception represents credential, network,
421
+ server, or other errors (using known nio exception types or message inspection),
422
+ chooses messages appropriate to the given context (verification vs non-verification),
423
+ logs them at the specified level ("error" or "warning"), prints concise feedback
424
+ for CLI users, and signals the exception was handled.
425
+
426
+ Parameters:
427
+ exception: The exception instance to classify and report.
428
+ context: Short context string describing the operation (e.g., "Password verification",
429
+ "Server logout"); used to select phrasing and to detect verification flows.
430
+ log_level: Logging level to use; accepted values are "error" (default) or "warning".
431
+
432
+ Returns:
433
+ bool: Always returns True to indicate the exception was handled and reported.
434
+ """
435
+ log_func = logger.error if log_level == "error" else logger.warning
436
+ emoji = "❌" if log_level == "error" else "⚠️ "
437
+ is_verification = "verification" in context.lower()
438
+
439
+ # Determine error category and details
440
+ error_category = None
441
+ error_detail = None
442
+
443
+ # Handle specific Matrix-nio exceptions
444
+ if isinstance(exception, (NioLoginError, NioLogoutError)) and hasattr(
445
+ exception, "status_code"
446
+ ):
447
+ if (
448
+ hasattr(exception, "errcode") and exception.errcode == "M_FORBIDDEN"
449
+ ) or exception.status_code == 401:
450
+ error_category = "credentials"
451
+ elif exception.status_code in [500, 502, 503]:
452
+ error_category = "server"
453
+ else:
454
+ error_category = "other"
455
+ error_detail = str(exception.status_code)
456
+ # Handle network/transport exceptions
457
+ elif isinstance(
458
+ exception,
459
+ (
460
+ NioLocalTransportError,
461
+ NioRemoteTransportError,
462
+ NioLocalProtocolError,
463
+ NioRemoteProtocolError,
464
+ ),
465
+ ):
466
+ error_category = "network"
467
+ else:
468
+ # Fallback to string matching for unknown exceptions
469
+ error_msg = str(exception).lower()
470
+ if "forbidden" in error_msg or "401" in error_msg:
471
+ error_category = "credentials"
472
+ elif (
473
+ "network" in error_msg
474
+ or "connection" in error_msg
475
+ or "timeout" in error_msg
476
+ ):
477
+ error_category = "network"
478
+ elif (
479
+ "server" in error_msg
480
+ or "500" in error_msg
481
+ or "502" in error_msg
482
+ or "503" in error_msg
483
+ ):
484
+ error_category = "server"
485
+ else:
486
+ error_category = "other"
487
+ error_detail = type(exception).__name__
488
+
489
+ # Generate appropriate messages based on category and context
490
+ if error_category == "credentials":
491
+ if is_verification:
492
+ log_func(f"{context} failed: Invalid credentials.")
493
+ log_func("Please check your username and password.")
494
+ print(f"{emoji} {context} failed: Invalid credentials.")
495
+ print("Please check your username and password.")
496
+ else:
497
+ log_func(
498
+ f"{context} failed due to invalid token (already logged out?), proceeding with local cleanup."
499
+ )
500
+ print(
501
+ f"{emoji} {context} failed due to invalid token (already logged out?), proceeding with local cleanup."
502
+ )
503
+ elif error_category == "network":
504
+ if is_verification:
505
+ log_func(f"{context} failed: Network connection error.")
506
+ log_func(
507
+ "Please check your internet connection and Matrix server availability."
508
+ )
509
+ print(f"{emoji} {context} failed: Network connection error.")
510
+ print(
511
+ "Please check your internet connection and Matrix server availability."
512
+ )
513
+ else:
514
+ log_func(
515
+ f"{context} failed due to network issues, proceeding with local cleanup."
516
+ )
517
+ print(
518
+ f"{emoji} {context} failed due to network issues, proceeding with local cleanup."
519
+ )
520
+ elif error_category == "server":
521
+ if is_verification:
522
+ log_func(f"{context} failed: Matrix server error.")
523
+ log_func(
524
+ "Please try again later or contact your Matrix server administrator."
525
+ )
526
+ print(f"{emoji} {context} failed: Matrix server error.")
527
+ print("Please try again later or contact your Matrix server administrator.")
528
+ else:
529
+ log_func(
530
+ f"{context} failed due to server error, proceeding with local cleanup."
531
+ )
532
+ print(
533
+ f"{emoji} {context} failed due to server error, proceeding with local cleanup."
534
+ )
535
+ else: # error_category == "other"
536
+ if is_verification:
537
+ log_func(f"{context} failed: {error_detail or 'Unknown error'}")
538
+ logger.debug(f"Full error details: {exception}")
539
+ print(f"{emoji} {context} failed: {error_detail or 'Unknown error'}")
540
+ else:
541
+ log_func(
542
+ f"{context} failed ({error_detail or 'Unknown error'}), proceeding with local cleanup."
543
+ )
544
+ print(
545
+ f"{emoji} {context} failed ({error_detail or 'Unknown error'}), proceeding with local cleanup."
546
+ )
547
+
548
+ return True
549
+
550
+
551
+ async def logout_matrix_bot(password: str):
552
+ """
553
+ Log out the configured Matrix account (if any), verify credentials, and remove local session data.
554
+
555
+ Performs an optional verification of the supplied Matrix password by performing a temporary login, attempts to log out the active server session (invalidating the access token), and removes local session artifacts (e.g., credentials.json and any E2EE store directories). If the stored credentials lack a user_id but include an access_token and homeserver, the function will try to fetch and persist the missing user_id before proceeding.
556
+
557
+ Parameters:
558
+ password (str): The Matrix account password used to verify the session before performing server logout.
559
+
560
+ Returns:
561
+ bool: True when local cleanup (and server logout, if attempted) completed successfully; False on failure.
562
+ If the matrix-nio dependency is not available the function prints an error and returns False.
563
+
564
+ Side effects:
565
+ - May update credentials.json if the user_id is fetched.
566
+ - Removes local session files and E2EE store directories when cleanup runs.
567
+ - Performs network requests to the homeserver for verification and logout when credentials are complete.
568
+ """
569
+
570
+ # Import inside function to avoid circular imports
571
+ from mmrelay.matrix_utils import (
572
+ MATRIX_LOGIN_TIMEOUT,
573
+ load_credentials,
574
+ )
575
+
576
+ # Check if matrix-nio is available
577
+ if AsyncClient is None:
578
+ logger.error("Matrix-nio library not available. Cannot perform logout.")
579
+ print("❌ Matrix-nio library not available. Cannot perform logout.")
580
+ return False
581
+
582
+ # Load current credentials
583
+ credentials = load_credentials()
584
+ if not credentials:
585
+ logger.info("No active session found. Already logged out.")
586
+ print("ℹ️ No active session found. Already logged out.")
587
+ return True
588
+
589
+ homeserver = credentials.get("homeserver")
590
+ user_id = credentials.get("user_id")
591
+ access_token = credentials.get("access_token")
592
+ device_id = credentials.get("device_id")
593
+
594
+ # If user_id is missing, try to fetch it using the access token
595
+ if not user_id and access_token and homeserver:
596
+ logger.info("user_id missing from credentials, attempting to fetch it...")
597
+ print("🔍 user_id missing from credentials, attempting to fetch it...")
598
+
599
+ try:
600
+ # Create SSL context for the temporary client
601
+ ssl_context = _create_ssl_context()
602
+
603
+ # Create a temporary client to fetch user_id
604
+ temp_client = AsyncClient(homeserver, ssl=ssl_context)
605
+ temp_client.access_token = access_token
606
+
607
+ # Fetch user_id using whoami
608
+ whoami_response = await asyncio.wait_for(
609
+ temp_client.whoami(),
610
+ timeout=MATRIX_LOGIN_TIMEOUT,
611
+ )
612
+
613
+ if hasattr(whoami_response, "user_id"):
614
+ user_id = whoami_response.user_id
615
+ logger.info(f"Successfully fetched user_id: {user_id}")
616
+ print(f"✅ Successfully fetched user_id: {user_id}")
617
+
618
+ # Update credentials with the fetched user_id
619
+ credentials["user_id"] = user_id
620
+ from mmrelay.config import save_credentials
621
+
622
+ save_credentials(credentials)
623
+ logger.info("Updated credentials.json with fetched user_id")
624
+ print("✅ Updated credentials.json with fetched user_id")
625
+ else:
626
+ logger.error("Failed to fetch user_id from whoami response")
627
+ print("❌ Failed to fetch user_id from whoami response")
628
+
629
+ except asyncio.TimeoutError:
630
+ logger.error("Timeout while fetching user_id")
631
+ print("❌ Timeout while fetching user_id")
632
+ except Exception as e:
633
+ logger.exception("Error fetching user_id")
634
+ print(f"❌ Error fetching user_id: {e}")
635
+ finally:
636
+ try:
637
+ await temp_client.close()
638
+ except Exception:
639
+ # Ignore errors when closing client during logout
640
+ pass
641
+
642
+ if not all([homeserver, user_id, access_token, device_id]):
643
+ logger.error("Invalid credentials found. Cannot verify logout.")
644
+ logger.info("Proceeding with local cleanup only...")
645
+ print("⚠️ Invalid credentials found. Cannot verify logout.")
646
+ print("Proceeding with local cleanup only...")
647
+
648
+ # Still try to clean up local files
649
+ success = _cleanup_local_session_data()
650
+ if success:
651
+ print("✅ Local cleanup completed successfully!")
652
+ else:
653
+ print("❌ Local cleanup completed with some errors.")
654
+ return success
655
+
656
+ logger.info(f"Verifying password for {user_id}...")
657
+ print(f"🔐 Verifying password for {user_id}...")
658
+
659
+ try:
660
+ # Create SSL context using certifi's certificates
661
+ ssl_context = _create_ssl_context()
662
+ if ssl_context is None:
663
+ logger.warning(
664
+ "Failed to create SSL context for password verification; falling back to default system SSL"
665
+ )
666
+
667
+ # Create a temporary client to verify the password
668
+ # We'll try to login with the password to verify it's correct
669
+ temp_client = AsyncClient(homeserver, user_id, ssl=ssl_context)
670
+
671
+ try:
672
+ # Attempt login with the provided password
673
+ response = await asyncio.wait_for(
674
+ temp_client.login(password, device_name="mmrelay-logout-verify"),
675
+ timeout=MATRIX_LOGIN_TIMEOUT,
676
+ )
677
+
678
+ if hasattr(response, "access_token"):
679
+ logger.info("Password verified successfully.")
680
+ print("✅ Password verified successfully.")
681
+
682
+ # Immediately logout the temporary session
683
+ await temp_client.logout()
684
+ else:
685
+ logger.error("Password verification failed.")
686
+ print("❌ Password verification failed.")
687
+ return False
688
+
689
+ except asyncio.TimeoutError:
690
+ logger.error(
691
+ "Password verification timed out. Please check your network connection."
692
+ )
693
+ print(
694
+ "❌ Password verification timed out. Please check your network connection."
695
+ )
696
+ return False
697
+ except Exception as e:
698
+ _handle_matrix_error(e, "Password verification", "error")
699
+ return False
700
+ finally:
701
+ await temp_client.close()
702
+
703
+ # Now logout the main session
704
+ logger.info("Logging out from Matrix server...")
705
+ print("🚪 Logging out from Matrix server...")
706
+ main_client = AsyncClient(homeserver, user_id, ssl=ssl_context)
707
+ main_client.restore_login(
708
+ user_id=user_id,
709
+ device_id=device_id,
710
+ access_token=access_token,
711
+ )
712
+
713
+ try:
714
+ # Logout from the server (invalidates the access token)
715
+ logout_response = await main_client.logout()
716
+ if hasattr(logout_response, "transport_response"):
717
+ logger.info("Successfully logged out from Matrix server.")
718
+ print("✅ Successfully logged out from Matrix server.")
719
+ else:
720
+ logger.warning(
721
+ "Logout response unclear, proceeding with local cleanup."
722
+ )
723
+ print("⚠️ Logout response unclear, proceeding with local cleanup.")
724
+ except Exception as e:
725
+ _handle_matrix_error(e, "Server logout", "warning")
726
+ logger.debug(f"Logout error details: {e}")
727
+ finally:
728
+ await main_client.close()
729
+
730
+ # Clear local session data
731
+ success = _cleanup_local_session_data()
732
+ if success:
733
+ print()
734
+ print("✅ Logout completed successfully!")
735
+ print("All Matrix sessions and local data have been cleared.")
736
+ print("Run 'mmrelay auth login' to authenticate again.")
737
+ else:
738
+ print()
739
+ print("⚠️ Logout completed with some errors.")
740
+ print("Some files may not have been removed due to permission issues.")
741
+ return success
742
+
743
+ except Exception as e:
744
+ logger.exception("Error during logout process")
745
+ print(f"❌ Error during logout process: {e}")
746
+ return False