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 +1 -1
- mmrelay/cli.py +287 -89
- mmrelay/message_queue.py +205 -54
- mmrelay/tools/sample_config.yaml +13 -12
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/METADATA +5 -5
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/RECORD +10 -10
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.1.dist-info}/top_level.txt +0 -0
mmrelay/__init__.py
CHANGED
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
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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)
|
|
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
|
|
594
|
+
Print a concise, user-facing analysis of E2EE readiness.
|
|
512
595
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
"ready", prints actionable
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1205
|
+
Run the Matrix bot login flow and return a CLI-style exit code.
|
|
1100
1206
|
|
|
1101
|
-
|
|
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
|
|
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
|
-
#
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1288
|
+
Print the Matrix authentication status by locating and reading a credentials.json file.
|
|
1129
1289
|
|
|
1130
|
-
Searches
|
|
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
|
|
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
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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'
|
|
1158
|
-
print(f" User ID: {credentials.get('user_id'
|
|
1159
|
-
print(f" Device ID: {credentials.get('device_id'
|
|
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
|
|
1360
|
+
Log out the Matrix bot and remove local session artifacts.
|
|
1173
1361
|
|
|
1174
|
-
Prompts for a verification password (unless
|
|
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
|
|
1178
|
-
password (str | None): If
|
|
1179
|
-
If
|
|
1180
|
-
yes (bool): If True, skip the
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
Activate the message queue processor with a minimum inter-message delay.
|
|
65
85
|
|
|
66
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
130
|
-
*args: Positional arguments
|
|
131
|
-
description (str, optional): Human-readable description for logging
|
|
132
|
-
mapping_info (dict, optional): Optional metadata
|
|
133
|
-
**kwargs: Keyword arguments
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
227
|
-
|
|
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.
|
|
272
|
-
time_since_last = 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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
476
|
+
Return True if it's currently safe to send a message over Meshtastic.
|
|
336
477
|
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
+
Store a sent message's mapping (mesh ID → Matrix event) and prune old mappings according to retention settings.
|
|
376
522
|
|
|
377
|
-
|
|
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
|
-
|
|
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
|
"""
|
mmrelay/tools/sample_config.yaml
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
matrix:
|
|
2
2
|
homeserver: https://example.matrix.org
|
|
3
|
-
|
|
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
|
-
#
|
|
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.
|
|
17
|
-
# 3.
|
|
18
|
-
# 4.
|
|
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.
|
|
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.
|
|
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
|
|
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=
|
|
2
|
-
mmrelay/cli.py,sha256=
|
|
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=
|
|
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=
|
|
40
|
-
mmrelay-1.2.
|
|
41
|
-
mmrelay-1.2.
|
|
42
|
-
mmrelay-1.2.
|
|
43
|
-
mmrelay-1.2.
|
|
44
|
-
mmrelay-1.2.
|
|
45
|
-
mmrelay-1.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|