feat: matrix-hermes project structure and Matrix authentication
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Matrix-Hermes Configuration
|
||||
# Copy this to .env and fill in your Matrix server details
|
||||
|
||||
# Required: your Matrix homeserver URL
|
||||
MATRIX_HOMESERVER=https://matrix.org
|
||||
|
||||
# Option A: Use an existing access token (preferred)
|
||||
# Get this from Element Web: Settings → Help & About → Access Token
|
||||
# Or use `matrix-hermes login` to generate one
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
|
||||
# Option B: Login with password (token auto-saved to ~/.hermes/matrix_sessions/)
|
||||
# MATRIX_USER_ID=@hermes:matrix.org
|
||||
# MATRIX_PASSWORD=your-password-here
|
||||
|
||||
# --- Optional settings ---
|
||||
|
||||
# Stable device ID for E2EE persistence across restarts
|
||||
# MATRIX_DEVICE_ID=
|
||||
|
||||
# Enable end-to-end encryption (requires mautrix[encryption])
|
||||
# MATRIX_ENCRYPTION=false
|
||||
|
||||
# HTTP(S) proxy for Matrix traffic
|
||||
# MATRIX_PROXY=
|
||||
|
||||
# Directory for E2EE key storage
|
||||
# MATRIX_STORE_DIR=
|
||||
|
||||
# Directory for session token persistence (default: ~/.hermes/matrix_sessions)
|
||||
# MATRIX_SESSION_DIR=
|
||||
|
||||
# --- Gateway-compatible settings ---
|
||||
|
||||
# Comma-separated list of allowed Matrix user IDs
|
||||
# MATRIX_ALLOWED_USERS=@alice:matrix.org,@bob:matrix.org
|
||||
|
||||
# Room ID for cron/notification delivery
|
||||
# MATRIX_HOME_ROOM=!abc123:matrix.org
|
||||
|
||||
# Require @mention in rooms to trigger the bot
|
||||
# MATRIX_REQUIRE_MENTION=true
|
||||
|
||||
# Auto-create threads for room messages
|
||||
# MATRIX_AUTO_THREAD=true
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Byte-compiled / __pycache__
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
*.egg-info/
|
||||
# Let egg-info stay if inside src/ — pip needs it. Better: just no.
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Session files (contains tokens!)
|
||||
matrix_sessions/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,57 @@
|
||||
# Matrix-Hermes
|
||||
|
||||
Matrix authentication and client integration for Hermes Agent.
|
||||
|
||||
## Overview
|
||||
|
||||
`matrix-hermes` provides authentication, client connectivity, and bridge
|
||||
utilities for integrating Hermes Agent with the Matrix protocol. Works
|
||||
with any Matrix homeserver (self-hosted or matrix.org).
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication**: Login with password or access token, device registration
|
||||
- **Client**: Async Matrix client wrapping mautrix SDK
|
||||
- **CLI**: Command-line tools for authentication setup and token management
|
||||
- **Config**: `.env`-based configuration compatible with Hermes gateway
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
matrix-hermes login --homeserver https://matrix.org --user @bot:matrix.org
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MATRIX_HOMESERVER` | Homeserver URL |
|
||||
| `MATRIX_ACCESS_TOKEN` | Access token (preferred) |
|
||||
| `MATRIX_USER_ID` | Full user ID (@user:server) |
|
||||
| `MATRIX_PASSWORD` | Password (alternative auth) |
|
||||
| `MATRIX_DEVICE_ID` | Stable device ID for E2EE |
|
||||
| `MATRIX_ENCRYPTION` | Enable E2EE (true/false) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
matrix-hermes/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── src/
|
||||
│ └── matrix_hermes/
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # Login, token, device management
|
||||
│ ├── client.py # Async Matrix client wrapper
|
||||
│ ├── config.py # Configuration from env/file
|
||||
│ └── cli.py # CLI entry point
|
||||
└── tests/
|
||||
├── conftest.py
|
||||
├── test_auth.py
|
||||
└── test_client.py
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,50 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "matrix-hermes"
|
||||
version = "0.1.0"
|
||||
description = "Matrix-Hermes integration: authentication, client, and bridge for Hermes Agent"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [{name = "Hermes Agent", email = "oplabs@ourpad.casa"}]
|
||||
keywords = ["matrix", "hermes", "chat", "bot", "authentication"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"mautrix>=0.20.0",
|
||||
"aiohttp>=3.9",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
encryption = ["mautrix[encryption]"]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
matrix-hermes = "matrix_hermes.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Matrix-Hermes: Matrix authentication and client integration for Hermes Agent.
|
||||
|
||||
Provides:
|
||||
- Authentication (login, token management, device registration)
|
||||
- Async client wrapper around mautrix
|
||||
- CLI tools for setup and token management
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["MatrixAuth", "MatrixClient", "MatrixConfig", "__version__"]
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Matrix authentication: login, token management, session persistence, and device registration.
|
||||
|
||||
Provides sync APIs for authenticating against any Matrix homeserver.
|
||||
Supports password-based login, access token management, device ID
|
||||
registration, logout, and persistent session caching on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from matrix_hermes.config import MatrixConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthResult:
|
||||
"""Result of a Matrix authentication attempt."""
|
||||
|
||||
success: bool
|
||||
"""True if authentication succeeded."""
|
||||
|
||||
user_id: str = ""
|
||||
"""The authenticated user's full Matrix ID (@user:server)."""
|
||||
|
||||
access_token: str = ""
|
||||
"""The access token for subsequent API calls."""
|
||||
|
||||
device_id: str = ""
|
||||
"""The device ID assigned or used for this session."""
|
||||
|
||||
homeserver: str = ""
|
||||
"""The homeserver that authenticated this session."""
|
||||
|
||||
error: str = ""
|
||||
"""Error message if authentication failed."""
|
||||
|
||||
raw: dict[str, Any] | None = None
|
||||
"""Raw response from the homeserver for debugging."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WhoAmIResult:
|
||||
"""Result of a token validation / whoami query."""
|
||||
|
||||
valid: bool
|
||||
user_id: str = ""
|
||||
device_id: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level HTTP helpers (sync, no mautrix dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
|
||||
"""POST JSON to a URL and return the parsed JSON response."""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return {"errcode": "M_HTTP_ERROR", "error": body}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"errcode": "M_CONNECTION_ERROR", "error": str(exc.reason)}
|
||||
|
||||
|
||||
def _get_json(url: str, token: str, timeout: int = 30) -> dict[str, Any]:
|
||||
"""GET JSON from a URL with Bearer token auth."""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return {"errcode": "M_HTTP_ERROR", "error": body}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"errcode": "M_CONNECTION_ERROR", "error": str(exc.reason)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _session_path(config: MatrixConfig) -> Path:
|
||||
"""Return the path to the session file for the configured user ID."""
|
||||
safe_name = (
|
||||
config.user_id.replace(":", "_").replace("/", "_")
|
||||
if config.user_id
|
||||
else "default"
|
||||
)
|
||||
return Path(config.resolved_session_dir) / f"{safe_name}.json"
|
||||
|
||||
|
||||
def save_session(auth: AuthResult, config: MatrixConfig | None = None) -> Optional[Path]:
|
||||
"""Persist authentication result to disk for later reuse.
|
||||
|
||||
Args:
|
||||
auth: Successful AuthResult to save.
|
||||
config: Optional config used to resolve the session directory.
|
||||
|
||||
Returns:
|
||||
Path to the saved session file, or None if saving failed.
|
||||
"""
|
||||
if not auth.success:
|
||||
return None
|
||||
|
||||
session_dir = Path(
|
||||
config.resolved_session_dir if config
|
||||
else os.path.expanduser("~/.hermes/matrix_sessions")
|
||||
)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = {
|
||||
"user_id": auth.user_id,
|
||||
"access_token": auth.access_token,
|
||||
"device_id": auth.device_id,
|
||||
"homeserver": auth.homeserver,
|
||||
}
|
||||
path = session_dir / f"{auth.user_id.replace(':', '_').replace('/', '_')}.json"
|
||||
try:
|
||||
path.write_text(json.dumps(data, indent=2))
|
||||
logger.info("Saved session to %s", path)
|
||||
return path
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to save session: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def load_session(config: MatrixConfig) -> Optional[AuthResult]:
|
||||
"""Load a previously saved session from disk.
|
||||
|
||||
Args:
|
||||
config: Config used to resolve the session directory and user ID.
|
||||
|
||||
Returns:
|
||||
AuthResult if a valid session file exists, otherwise None.
|
||||
"""
|
||||
path = _session_path(config)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("Corrupt session file %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
if "access_token" not in data:
|
||||
return None
|
||||
|
||||
logger.info("Loaded session from %s (user: %s)", path, data.get("user_id", "?"))
|
||||
return AuthResult(
|
||||
success=True,
|
||||
user_id=data.get("user_id", ""),
|
||||
access_token=data.get("access_token", ""),
|
||||
device_id=data.get("device_id", ""),
|
||||
homeserver=data.get("homeserver", ""),
|
||||
)
|
||||
|
||||
|
||||
def remove_session(config: MatrixConfig) -> bool:
|
||||
"""Delete a persisted session file.
|
||||
|
||||
Args:
|
||||
config: Config used to resolve the session path.
|
||||
|
||||
Returns:
|
||||
True if the file was deleted, False if it didn't exist.
|
||||
"""
|
||||
path = _session_path(config)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.info("Removed session: %s", path)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def login(
|
||||
homeserver: str,
|
||||
user_id: str = "",
|
||||
password: str = "",
|
||||
device_id: str = "",
|
||||
access_token: str = "",
|
||||
) -> AuthResult:
|
||||
"""Authenticate with a Matrix homeserver.
|
||||
|
||||
Three authentication methods are supported (tried in order):
|
||||
1. ``access_token`` — use an existing token directly (validated via /whoami).
|
||||
2. ``password`` — login with user_id + password, get a new token.
|
||||
3. If neither is provided, returns an error.
|
||||
|
||||
Args:
|
||||
homeserver: Matrix homeserver URL (e.g. https://matrix.org).
|
||||
user_id: Full user ID (@user:server). Required for password login.
|
||||
password: User password. Required for password login.
|
||||
device_id: Optional stable device ID.
|
||||
access_token: Existing access token to validate.
|
||||
|
||||
Returns:
|
||||
AuthResult with success status and token/user details.
|
||||
"""
|
||||
homeserver = homeserver.rstrip("/")
|
||||
|
||||
# 1. Validate existing token
|
||||
if access_token:
|
||||
whoami = _validate_token(homeserver, access_token)
|
||||
if whoami.valid:
|
||||
logger.info("Token validation succeeded for %s", whoami.user_id)
|
||||
return AuthResult(
|
||||
success=True,
|
||||
user_id=whoami.user_id,
|
||||
access_token=access_token,
|
||||
device_id=whoami.device_id,
|
||||
homeserver=homeserver,
|
||||
)
|
||||
logger.warning("Token validation failed: %s", whoami.error)
|
||||
|
||||
# 2. Password login
|
||||
if password:
|
||||
if not user_id:
|
||||
return AuthResult(
|
||||
success=False,
|
||||
error="MATRIX_USER_ID is required for password login",
|
||||
)
|
||||
return _password_login(homeserver, user_id, password, device_id)
|
||||
|
||||
# 3. Nothing to try
|
||||
return AuthResult(
|
||||
success=False,
|
||||
error=(
|
||||
"No valid authentication method. "
|
||||
"Provide MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def login_from_config(config: MatrixConfig) -> AuthResult:
|
||||
"""Authenticate using a MatrixConfig instance.
|
||||
|
||||
Resolution order:
|
||||
1. ``access_token`` from config (validated via /whoami)
|
||||
2. Session file on disk (from a previous password login)
|
||||
3. ``user_id`` + ``password`` from config → password login → persist session
|
||||
|
||||
Args:
|
||||
config: MatrixConfig with connection parameters.
|
||||
|
||||
Returns:
|
||||
AuthResult with success status.
|
||||
"""
|
||||
# 1. Explicit access token
|
||||
if config.access_token:
|
||||
result = login(
|
||||
homeserver=config.homeserver,
|
||||
access_token=config.access_token,
|
||||
)
|
||||
if result.success:
|
||||
return result
|
||||
logger.warning("Explicit access token invalid, falling through...")
|
||||
|
||||
# 2. Session file on disk
|
||||
if not config.access_token:
|
||||
session = load_session(config)
|
||||
if session:
|
||||
result = login(
|
||||
homeserver=session.homeserver or config.homeserver,
|
||||
access_token=session.access_token,
|
||||
)
|
||||
if result.success:
|
||||
return result
|
||||
logger.warning("Session token invalid, removing session file...")
|
||||
remove_session(config)
|
||||
|
||||
# 3. Password login
|
||||
result = login(
|
||||
homeserver=config.homeserver,
|
||||
user_id=config.user_id,
|
||||
password=config.password,
|
||||
device_id=config.device_id,
|
||||
)
|
||||
|
||||
# Persist session on success
|
||||
if result.success:
|
||||
save_session(result, config)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_token(homeserver: str, access_token: str) -> WhoAmIResult:
|
||||
"""Check whether an access token is still valid.
|
||||
|
||||
Calls the /_matrix/client/v3/account/whoami endpoint.
|
||||
|
||||
Args:
|
||||
homeserver: Matrix homeserver URL.
|
||||
access_token: Access token to validate.
|
||||
|
||||
Returns:
|
||||
WhoAmIResult indicating validity and user info.
|
||||
"""
|
||||
return _validate_token(homeserver, access_token)
|
||||
|
||||
|
||||
def logout(
|
||||
homeserver: str,
|
||||
access_token: str,
|
||||
) -> bool:
|
||||
"""Log out of a Matrix session, invalidating the access token.
|
||||
|
||||
Calls POST /_matrix/client/v3/logout.
|
||||
|
||||
Args:
|
||||
homeserver: Matrix homeserver URL.
|
||||
access_token: Access token to invalidate.
|
||||
|
||||
Returns:
|
||||
True if logout succeeded, False otherwise.
|
||||
"""
|
||||
homeserver = homeserver.rstrip("/")
|
||||
url = f"{homeserver}/_matrix/client/v3/logout"
|
||||
resp = _post_json(url, {}, timeout=15)
|
||||
if resp.get("errcode"):
|
||||
logger.error("Logout failed: %s", resp.get("error", resp.get("errcode")))
|
||||
return False
|
||||
logger.info("Logged out successfully")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _password_login(
|
||||
homeserver: str,
|
||||
user_id: str,
|
||||
password: str,
|
||||
device_id: str = "",
|
||||
) -> AuthResult:
|
||||
"""Perform password-based login against the Matrix homeserver.
|
||||
|
||||
Uses POST /_matrix/client/v3/login with type='m.login.password'.
|
||||
"""
|
||||
url = f"{homeserver}/_matrix/client/v3/login"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"type": "m.login.password",
|
||||
"user": user_id,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
if device_id:
|
||||
payload["device_id"] = device_id
|
||||
|
||||
# Optional initial_device_display_name
|
||||
payload["initial_device_display_name"] = "matrix-hermes"
|
||||
|
||||
logger.info("Logging in as %s at %s", user_id, homeserver)
|
||||
resp = _post_json(url, payload)
|
||||
|
||||
if resp.get("errcode"):
|
||||
return AuthResult(
|
||||
success=False,
|
||||
homeserver=homeserver,
|
||||
error=f"{resp.get('errcode')}: {resp.get('error', 'Unknown error')}",
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
return AuthResult(
|
||||
success=True,
|
||||
user_id=resp.get("user_id", user_id),
|
||||
access_token=resp.get("access_token", ""),
|
||||
device_id=resp.get("device_id", device_id or ""),
|
||||
homeserver=homeserver,
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
|
||||
def _validate_token(homeserver: str, access_token: str) -> WhoAmIResult:
|
||||
"""Call /_matrix/client/v3/account/whoami to validate a token."""
|
||||
url = f"{homeserver.rstrip('/')}/_matrix/client/v3/account/whoami"
|
||||
resp = _get_json(url, access_token)
|
||||
|
||||
if resp.get("errcode"):
|
||||
return WhoAmIResult(
|
||||
valid=False,
|
||||
error=f"{resp.get('errcode')}: {resp.get('error', 'Unknown error')}",
|
||||
)
|
||||
|
||||
return WhoAmIResult(
|
||||
valid=True,
|
||||
user_id=resp.get("user_id", ""),
|
||||
device_id=resp.get("device_id", ""),
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""CLI for Matrix-Hermes: login, token management, and status checks.
|
||||
|
||||
Usage:
|
||||
matrix-hermes login --homeserver URL [--user @user:server] [--password PASS]
|
||||
matrix-hermes whoami --homeserver URL --token TOKEN
|
||||
matrix-hermes logout --homeserver URL --token TOKEN
|
||||
matrix-hermes check # Check config from env
|
||||
matrix-hermes show-config # Show current config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from matrix_hermes.auth import login, logout, validate_token, AuthResult
|
||||
from matrix_hermes.config import MatrixConfig
|
||||
|
||||
|
||||
def _env_or_arg(arg_value: str, env_var: str) -> str:
|
||||
"""Return arg_value if set, otherwise the env var."""
|
||||
return arg_value or os.getenv(env_var, "")
|
||||
|
||||
|
||||
def cmd_login(args: argparse.Namespace) -> int:
|
||||
"""Handle the 'login' subcommand."""
|
||||
homeserver = _env_or_arg(args.homeserver, "MATRIX_HOMESERVER")
|
||||
user_id = _env_or_arg(args.user, "MATRIX_USER_ID")
|
||||
password = _env_or_arg(args.password, "MATRIX_PASSWORD")
|
||||
token = _env_or_arg(args.token, "MATRIX_ACCESS_TOKEN")
|
||||
device_id = _env_or_arg(args.device_id, "MATRIX_DEVICE_ID")
|
||||
|
||||
if not homeserver:
|
||||
print("Error: --homeserver or MATRIX_HOMESERVER is required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
result = login(
|
||||
homeserver=homeserver,
|
||||
user_id=user_id,
|
||||
password=password,
|
||||
device_id=device_id,
|
||||
access_token=token,
|
||||
)
|
||||
|
||||
return _print_auth_result(result)
|
||||
|
||||
|
||||
def cmd_whoami(args: argparse.Namespace) -> int:
|
||||
"""Handle the 'whoami' subcommand."""
|
||||
homeserver = _env_or_arg(args.homeserver, "MATRIX_HOMESERVER")
|
||||
token = _env_or_arg(args.token, "MATRIX_ACCESS_TOKEN")
|
||||
|
||||
if not homeserver or not token:
|
||||
print("Error: --homeserver and --token (or env vars) are required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
result = validate_token(homeserver, token)
|
||||
if result.valid:
|
||||
print(f"Token valid — user: {result.user_id}, device: {result.device_id}")
|
||||
return 0
|
||||
else:
|
||||
print(f"Token invalid: {result.error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_logout(args: argparse.Namespace) -> int:
|
||||
"""Handle the 'logout' subcommand."""
|
||||
homeserver = _env_or_arg(args.homeserver, "MATRIX_HOMESERVER")
|
||||
token = _env_or_arg(args.token, "MATRIX_ACCESS_TOKEN")
|
||||
|
||||
if not homeserver or not token:
|
||||
print("Error: --homeserver and --token (or env vars) are required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if logout(homeserver, token):
|
||||
print("Logged out successfully")
|
||||
return 0
|
||||
else:
|
||||
print("Logout failed", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> int:
|
||||
"""Handle the 'check' subcommand — validate config from environment."""
|
||||
config = MatrixConfig.from_env(
|
||||
env_file=args.env_file or None,
|
||||
)
|
||||
|
||||
if not config.is_configured():
|
||||
print("Matrix not configured. Required env vars:")
|
||||
print(" MATRIX_HOMESERVER — homeserver URL")
|
||||
print(" MATRIX_ACCESS_TOKEN — access token (preferred)")
|
||||
print(" or")
|
||||
print(" MATRIX_USER_ID + MATRIX_PASSWORD")
|
||||
return 1
|
||||
|
||||
errors = config.validate()
|
||||
if errors:
|
||||
for err in errors:
|
||||
print(f"Config error: {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Homeserver : {config.homeserver}")
|
||||
if config.user_id:
|
||||
print(f"User ID : {config.user_id}")
|
||||
if config.access_token:
|
||||
print(f"Token : {_mask_token(config.access_token)}")
|
||||
if config.device_id:
|
||||
print(f"Device ID : {config.device_id}")
|
||||
print(f"Encryption : {'enabled' if config.encryption else 'disabled'}")
|
||||
if config.allowed_users:
|
||||
print(f"Allowed users: {', '.join(config.allowed_users)}")
|
||||
print("\nConfig valid ✓")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show_config(args: argparse.Namespace) -> int:
|
||||
"""Handle the 'show-config' subcommand — dump config as env vars."""
|
||||
config = MatrixConfig.from_env(
|
||||
env_file=args.env_file or None,
|
||||
)
|
||||
|
||||
lines = []
|
||||
if args.redact and config.access_token:
|
||||
token = _mask_token(config.access_token)
|
||||
else:
|
||||
token = config.access_token
|
||||
|
||||
for key, value in [
|
||||
("MATRIX_HOMESERVER", config.homeserver),
|
||||
("MATRIX_USER_ID", config.user_id),
|
||||
("MATRIX_ACCESS_TOKEN", token),
|
||||
("MATRIX_PASSWORD", "***" if config.password and args.redact else config.password),
|
||||
("MATRIX_DEVICE_ID", config.device_id),
|
||||
("MATRIX_ENCRYPTION", "true" if config.encryption else "false"),
|
||||
("MATRIX_PROXY", config.proxy),
|
||||
("MATRIX_ALLOWED_USERS", ",".join(config.allowed_users)),
|
||||
("MATRIX_HOME_ROOM", config.home_room),
|
||||
("MATRIX_REQUIRE_MENTION", "true" if config.require_mention else "false"),
|
||||
("MATRIX_AUTO_THREAD", "true" if config.auto_thread else "false"),
|
||||
]:
|
||||
if value:
|
||||
lines.append(f"{key}={value}")
|
||||
|
||||
print("\n".join(lines))
|
||||
return 0
|
||||
|
||||
|
||||
def _print_auth_result(result: AuthResult) -> int:
|
||||
"""Pretty-print an AuthResult and return appropriate exit code."""
|
||||
if result.success:
|
||||
print("Login successful!")
|
||||
print(f" User ID : {result.user_id}")
|
||||
print(f" Homeserver : {result.homeserver}")
|
||||
if result.device_id:
|
||||
print(f" Device ID : {result.device_id}")
|
||||
print(f" Token : {result.access_token}")
|
||||
print()
|
||||
print("Add to your .env file:")
|
||||
print(f" MATRIX_HOMESERVER={result.homeserver}")
|
||||
print(f" MATRIX_ACCESS_TOKEN={result.access_token}")
|
||||
print(f" MATRIX_USER_ID={result.user_id}")
|
||||
return 0
|
||||
else:
|
||||
print(f"Login failed: {result.error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _mask_token(token: str, visible: int = 6) -> str:
|
||||
"""Mask a token, showing only the first and last few characters."""
|
||||
if len(token) <= visible * 2:
|
||||
return "*" * len(token)
|
||||
return token[:visible] + "..." + token[-visible:]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="matrix-hermes",
|
||||
description="Matrix-Hermes: authentication and client CLI",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# login
|
||||
p_login = sub.add_parser("login", help="Authenticate with a Matrix homeserver")
|
||||
p_login.add_argument("--homeserver", "-H", help="Homeserver URL")
|
||||
p_login.add_argument("--user", "-u", help="Full Matrix user ID (@user:server)")
|
||||
p_login.add_argument("--password", "-p", help="Password for login")
|
||||
p_login.add_argument("--token", "-t", help="Existing access token to validate")
|
||||
p_login.add_argument("--device-id", "-d", help="Stable device ID")
|
||||
|
||||
# whoami
|
||||
p_whoami = sub.add_parser("whoami", help="Validate an access token")
|
||||
p_whoami.add_argument("--homeserver", "-H", help="Homeserver URL")
|
||||
p_whoami.add_argument("--token", "-t", help="Access token to validate")
|
||||
|
||||
# logout
|
||||
p_logout = sub.add_parser("logout", help="Invalidate an access token")
|
||||
p_logout.add_argument("--homeserver", "-H", help="Homeserver URL")
|
||||
p_logout.add_argument("--token", "-t", help="Access token to invalidate")
|
||||
|
||||
# check
|
||||
p_check = sub.add_parser("check", help="Check Matrix configuration from env")
|
||||
p_check.add_argument("--env-file", help="Path to .env file to load")
|
||||
|
||||
# show-config
|
||||
p_show = sub.add_parser("show-config", help="Show current config as env vars")
|
||||
p_show.add_argument("--env-file", help="Path to .env file to load")
|
||||
p_show.add_argument("--redact", action="store_true", help="Redact sensitive values")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
_COMMAND_MAP = {
|
||||
"login": cmd_login,
|
||||
"whoami": cmd_whoami,
|
||||
"logout": cmd_logout,
|
||||
"check": cmd_check,
|
||||
"show-config": cmd_show_config,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point. Returns exit code."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
handler = _COMMAND_MAP.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
return handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Async Matrix client wrapper built on mautrix.
|
||||
|
||||
Provides a simplified async interface for connecting to Matrix,
|
||||
sending messages, joining rooms, and handling events. Designed to
|
||||
work with the Hermes gateway Matrix adapter conventions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
||||
|
||||
from matrix_hermes.auth import AuthResult, login_from_config
|
||||
from matrix_hermes.config import MatrixConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixMessage:
|
||||
"""A received Matrix message event."""
|
||||
|
||||
event_id: str
|
||||
"""The event ID."""
|
||||
|
||||
room_id: str
|
||||
"""Room where the message was sent."""
|
||||
|
||||
sender: str
|
||||
"""Full Matrix user ID of the sender."""
|
||||
|
||||
body: str
|
||||
"""Message body text."""
|
||||
|
||||
msg_type: str = "m.text"
|
||||
"""Message type: m.text, m.image, m.file, etc."""
|
||||
|
||||
raw: dict[str, Any] | None = None
|
||||
"""Raw event dict from the homeserver."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
"""Result of sending a Matrix message."""
|
||||
|
||||
success: bool
|
||||
"""True if the message was sent."""
|
||||
|
||||
event_id: str = ""
|
||||
"""The event ID of the sent message."""
|
||||
|
||||
error: str = ""
|
||||
"""Error message if sending failed."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MatrixClient:
|
||||
"""Async Matrix client wrapping mautrix.
|
||||
|
||||
Provides a simplified interface for common operations:
|
||||
- Connect and authenticate
|
||||
- Send/receive messages
|
||||
- Join/leave rooms
|
||||
- Sync event listening
|
||||
|
||||
Example:
|
||||
config = MatrixConfig.from_env()
|
||||
client = MatrixClient(config)
|
||||
await client.connect()
|
||||
await client.send_message("!room:server", "Hello Matrix!")
|
||||
await client.disconnect()
|
||||
"""
|
||||
|
||||
def __init__(self, config: MatrixConfig):
|
||||
"""Initialize the client.
|
||||
|
||||
Args:
|
||||
config: MatrixConfig with connection parameters.
|
||||
"""
|
||||
self._config = config
|
||||
self._client: Any = None # mautrix.client.Client
|
||||
self._connected = False
|
||||
self._message_handlers: list[
|
||||
Callable[[MatrixMessage], Awaitable[None]]
|
||||
] = []
|
||||
self._sync_task: Optional[asyncio.Task] = None
|
||||
self._closing = False
|
||||
|
||||
# -- Properties ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""True if the client is connected and authenticated."""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def user_id(self) -> str:
|
||||
"""The authenticated user's Matrix ID, or empty string if not connected."""
|
||||
return self._config.user_id or ""
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------
|
||||
|
||||
async def connect(self) -> AuthResult:
|
||||
"""Connect to the Matrix homeserver and authenticate.
|
||||
|
||||
Uses the configured access token or password to authenticate.
|
||||
On success, the client is ready to send messages and listen for events.
|
||||
|
||||
Returns:
|
||||
AuthResult indicating success/failure and user details.
|
||||
"""
|
||||
if self._connected:
|
||||
return AuthResult(
|
||||
success=True,
|
||||
user_id=self._config.user_id or "",
|
||||
access_token=self._config.access_token,
|
||||
homeserver=self._config.homeserver,
|
||||
)
|
||||
|
||||
# Sync auth to get/validate token
|
||||
auth = login_from_config(self._config)
|
||||
if not auth.success:
|
||||
return auth
|
||||
|
||||
# Store resolved credentials
|
||||
self._config.access_token = auth.access_token
|
||||
self._config.user_id = auth.user_id
|
||||
self._config.device_id = auth.device_id
|
||||
|
||||
# Lazy-import mautrix
|
||||
try:
|
||||
from mautrix.client import Client
|
||||
from mautrix.types import PresenceState
|
||||
except ImportError as exc:
|
||||
return AuthResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"mautrix not installed: {exc}. "
|
||||
"Run: pip install mautrix"
|
||||
),
|
||||
homeserver=self._config.homeserver,
|
||||
)
|
||||
|
||||
# Build client
|
||||
self._client = Client(
|
||||
mxid=self._config.user_id,
|
||||
base_url=self._config.homeserver,
|
||||
token=self._config.access_token,
|
||||
)
|
||||
|
||||
# Set device ID if configured
|
||||
if self._config.device_id and hasattr(self._client, "device_id"):
|
||||
self._client.device_id = self._config.device_id
|
||||
|
||||
self._connected = True
|
||||
self._closing = False
|
||||
logger.info("Connected to %s as %s", self._config.homeserver, self._config.user_id)
|
||||
return auth
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the Matrix homeserver."""
|
||||
self._closing = True
|
||||
if self._sync_task and not self._sync_task.done():
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._client:
|
||||
await self._client.stop()
|
||||
self._connected = False
|
||||
self._sync_task = None
|
||||
logger.info("Disconnected from Matrix")
|
||||
|
||||
async def __aenter__(self) -> "MatrixClient":
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
await self.disconnect()
|
||||
|
||||
# -- Messaging -----------------------------------------------------------
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
room_id: str,
|
||||
body: str,
|
||||
msg_type: str = "m.text",
|
||||
thread_root: str = "",
|
||||
reply_to: str = "",
|
||||
) -> SendResult:
|
||||
"""Send a message to a Matrix room.
|
||||
|
||||
Args:
|
||||
room_id: Target room ID.
|
||||
body: Message body text.
|
||||
msg_type: Message type (default: m.text).
|
||||
thread_root: Optional event ID to thread under.
|
||||
reply_to: Optional event ID to reply to.
|
||||
|
||||
Returns:
|
||||
SendResult with success status and event ID.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
content: dict[str, Any] = {
|
||||
"msgtype": msg_type,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
if reply_to:
|
||||
content["m.relates_to"] = {
|
||||
"m.in_reply_to": {"event_id": reply_to}
|
||||
}
|
||||
elif thread_root:
|
||||
content["m.relates_to"] = {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": thread_root,
|
||||
}
|
||||
|
||||
event_id = await self._client.send_message(room_id, content)
|
||||
return SendResult(success=True, event_id=str(event_id))
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send message: %s", exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
room_id: str,
|
||||
image_path: str,
|
||||
caption: str = "",
|
||||
thread_root: str = "",
|
||||
) -> SendResult:
|
||||
"""Send an image to a Matrix room.
|
||||
|
||||
Args:
|
||||
room_id: Target room ID.
|
||||
image_path: Local path to the image file.
|
||||
caption: Optional caption text.
|
||||
thread_root: Optional event ID to thread under.
|
||||
|
||||
Returns:
|
||||
SendResult with success status and event ID.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_image(
|
||||
room_id,
|
||||
image_path,
|
||||
caption=caption,
|
||||
thread_root=thread_root or None,
|
||||
)
|
||||
return SendResult(success=True, event_id=str(event_id))
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send image: %s", exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
# -- Room management -----------------------------------------------------
|
||||
|
||||
async def join_room(self, room_id: str) -> bool:
|
||||
"""Join a Matrix room.
|
||||
|
||||
Args:
|
||||
room_id: Room ID or alias to join.
|
||||
|
||||
Returns:
|
||||
True if joined successfully.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
return False
|
||||
try:
|
||||
await self._client.join_room(room_id)
|
||||
logger.info("Joined room %s", room_id)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to join room %s: %s", room_id, exc)
|
||||
return False
|
||||
|
||||
async def leave_room(self, room_id: str) -> bool:
|
||||
"""Leave a Matrix room.
|
||||
|
||||
Args:
|
||||
room_id: Room ID to leave.
|
||||
|
||||
Returns:
|
||||
True if left successfully.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
return False
|
||||
try:
|
||||
await self._client.leave_room(room_id)
|
||||
logger.info("Left room %s", room_id)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to leave room %s: %s", room_id, exc)
|
||||
return False
|
||||
|
||||
async def get_joined_rooms(self) -> list[str]:
|
||||
"""Get the list of rooms the user has joined.
|
||||
|
||||
Returns:
|
||||
List of room IDs.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
return []
|
||||
try:
|
||||
rooms = await self._client.get_joined_rooms()
|
||||
return [str(r) for r in rooms]
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get joined rooms: %s", exc)
|
||||
return []
|
||||
|
||||
# -- Event listeners -----------------------------------------------------
|
||||
|
||||
def on_message(
|
||||
self,
|
||||
handler: Callable[[MatrixMessage], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Register a message handler.
|
||||
|
||||
Args:
|
||||
handler: Async callable that receives each MatrixMessage.
|
||||
"""
|
||||
self._message_handlers.append(handler)
|
||||
|
||||
async def start_listening(
|
||||
self,
|
||||
poll_interval: float = 1.0,
|
||||
) -> None:
|
||||
"""Start listening for incoming Matrix events.
|
||||
|
||||
This runs a sync loop that polls for new events and dispatches
|
||||
them to registered message handlers. Runs until disconnect()
|
||||
is called.
|
||||
|
||||
Args:
|
||||
poll_interval: Seconds between sync polls.
|
||||
"""
|
||||
if not self._connected or not self._client:
|
||||
raise RuntimeError("Must connect() before start_listening()")
|
||||
|
||||
logger.info("Starting event listener (poll_interval=%.1fs)", poll_interval)
|
||||
|
||||
while not self._closing:
|
||||
try:
|
||||
await self._client.sync()
|
||||
# Process any new messages from the sync
|
||||
# (mautrix handles callbacks internally; this is a simplified API)
|
||||
await asyncio.sleep(poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.error("Sync error: %s", exc)
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quick helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def quick_connect(
|
||||
homeserver: str = "",
|
||||
access_token: str = "",
|
||||
user_id: str = "",
|
||||
password: str = "",
|
||||
) -> MatrixClient | None:
|
||||
"""Quickly create and connect a MatrixClient from raw parameters.
|
||||
|
||||
Args:
|
||||
homeserver: Matrix homeserver URL.
|
||||
access_token: Existing access token (preferred).
|
||||
user_id: Full user ID (required for password login).
|
||||
password: Password for login (alternative to token).
|
||||
|
||||
Returns:
|
||||
Connected MatrixClient, or None if connection failed.
|
||||
"""
|
||||
config = MatrixConfig(
|
||||
homeserver=homeserver or "",
|
||||
access_token=access_token or "",
|
||||
user_id=user_id or "",
|
||||
password=password or "",
|
||||
)
|
||||
|
||||
client = MatrixClient(config)
|
||||
auth = await client.connect()
|
||||
if not auth.success:
|
||||
logger.error("Connection failed: %s", auth.error)
|
||||
return None
|
||||
return client
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Configuration management for Matrix-Hermes.
|
||||
|
||||
Loads settings from environment variables and/or .env files, compatible
|
||||
with the Hermes gateway Matrix adapter conventions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixConfig:
|
||||
"""Matrix connection and authentication configuration.
|
||||
|
||||
All fields can be set via environment variables or passed directly.
|
||||
Environment variable names match the Hermes gateway conventions.
|
||||
"""
|
||||
|
||||
homeserver: str = ""
|
||||
"""Matrix homeserver URL (e.g. https://matrix.example.org)."""
|
||||
|
||||
access_token: str = ""
|
||||
"""Access token for authenticated sessions (preferred over password)."""
|
||||
|
||||
user_id: str = ""
|
||||
"""Full Matrix user ID (@user:server)."""
|
||||
|
||||
password: str = ""
|
||||
"""Password for login (alternative to access token)."""
|
||||
|
||||
device_id: str = ""
|
||||
"""Stable device ID for E2EE persistence across restarts."""
|
||||
|
||||
encryption: bool = False
|
||||
"""Enable end-to-end encryption."""
|
||||
|
||||
proxy: str = ""
|
||||
"""HTTP(S) or SOCKS proxy URL for Matrix traffic."""
|
||||
|
||||
store_dir: str = ""
|
||||
"""Directory for E2EE key storage and sync state."""
|
||||
|
||||
session_dir: str = ""
|
||||
"""Directory for persisted session tokens (default: ~/.hermes/matrix_sessions)."""
|
||||
|
||||
# Gateway-compatible extras
|
||||
allowed_users: list[str] = field(default_factory=list)
|
||||
"""Whitelist of user IDs allowed to interact with the bot."""
|
||||
|
||||
home_room: str = ""
|
||||
"""Room ID for cron/notification delivery."""
|
||||
|
||||
require_mention: bool = True
|
||||
"""Require @mention in rooms to trigger bot."""
|
||||
|
||||
auto_thread: bool = True
|
||||
"""Auto-create threads for room messages."""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env_file: str | Path | None = None) -> "MatrixConfig":
|
||||
"""Load configuration from environment variables.
|
||||
|
||||
Optionally loads a .env file first via python-dotenv.
|
||||
|
||||
Args:
|
||||
env_file: Path to a .env file to load before reading os.environ.
|
||||
|
||||
Returns:
|
||||
MatrixConfig populated from environment variables.
|
||||
"""
|
||||
if env_file:
|
||||
_load_dotenv(env_file)
|
||||
|
||||
return cls(
|
||||
homeserver=os.getenv("MATRIX_HOMESERVER", ""),
|
||||
access_token=os.getenv("MATRIX_ACCESS_TOKEN", ""),
|
||||
user_id=os.getenv("MATRIX_USER_ID", ""),
|
||||
password=os.getenv("MATRIX_PASSWORD", ""),
|
||||
device_id=os.getenv("MATRIX_DEVICE_ID", ""),
|
||||
encryption=os.getenv("MATRIX_ENCRYPTION", "").lower()
|
||||
in {"true", "1", "yes"},
|
||||
proxy=os.getenv("MATRIX_PROXY", ""),
|
||||
store_dir=os.getenv("MATRIX_STORE_DIR", ""),
|
||||
session_dir=os.getenv("MATRIX_SESSION_DIR", ""),
|
||||
allowed_users=_parse_csv(os.getenv("MATRIX_ALLOWED_USERS", "")),
|
||||
home_room=os.getenv("MATRIX_HOME_ROOM", ""),
|
||||
require_mention=os.getenv("MATRIX_REQUIRE_MENTION", "true").lower()
|
||||
not in {"false", "0", "no"},
|
||||
auto_thread=os.getenv("MATRIX_AUTO_THREAD", "true").lower()
|
||||
in {"true", "1", "yes"},
|
||||
)
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Check that required fields are set.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty means valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not self.homeserver:
|
||||
errors.append("MATRIX_HOMESERVER is required")
|
||||
if not self.access_token and not self.password:
|
||||
errors.append(
|
||||
"Either MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD is required"
|
||||
)
|
||||
if not self.user_id and self.password:
|
||||
errors.append(
|
||||
"MATRIX_USER_ID is required when using password authentication"
|
||||
)
|
||||
return errors
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True if the minimum required config is present."""
|
||||
return bool(self.homeserver and (self.access_token or self.password))
|
||||
|
||||
@property
|
||||
def resolved_session_dir(self) -> str:
|
||||
"""Return the resolved session directory path.
|
||||
|
||||
Uses ``session_dir`` if set, otherwise defaults to
|
||||
``~/.hermes/matrix_sessions``.
|
||||
"""
|
||||
if self.session_dir:
|
||||
return self.session_dir
|
||||
return str(Path.home() / ".hermes" / "matrix_sessions")
|
||||
|
||||
|
||||
def _parse_csv(value: str) -> list[str]:
|
||||
"""Parse a comma-separated string into a list of stripped, non-empty values."""
|
||||
if not value:
|
||||
return []
|
||||
return [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
|
||||
def _load_dotenv(env_file: str | Path) -> None:
|
||||
"""Load a .env file using python-dotenv if available."""
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
pass # python-dotenv not installed; env vars must be set externally
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Test configuration for matrix-hermes."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Remove Matrix env vars for clean test state."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith("MATRIX_"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env(monkeypatch):
|
||||
"""Set up a minimal valid Matrix environment."""
|
||||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test_token_abc123")
|
||||
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen():
|
||||
"""Mock urllib.request.urlopen for HTTP testing."""
|
||||
with patch("urllib.request.urlopen") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Mock urllib.request.Request for HTTP testing."""
|
||||
with patch("urllib.request.Request") as mock:
|
||||
yield mock
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Test Matrix authentication: login, token validation, session persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from matrix_hermes.auth import (
|
||||
AuthResult,
|
||||
WhoAmIResult,
|
||||
_password_login,
|
||||
_validate_token,
|
||||
load_session,
|
||||
login,
|
||||
login_from_config,
|
||||
logout,
|
||||
remove_session,
|
||||
save_session,
|
||||
validate_token,
|
||||
)
|
||||
from matrix_hermes.config import MatrixConfig
|
||||
|
||||
|
||||
class TestAuthResult:
|
||||
"""Tests for the AuthResult dataclass."""
|
||||
|
||||
def test_success_result(self) -> None:
|
||||
result = AuthResult(
|
||||
success=True,
|
||||
user_id="@test:matrix.org",
|
||||
access_token="syt_abc",
|
||||
device_id="DEV123",
|
||||
homeserver="https://matrix.org",
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.user_id == "@test:matrix.org"
|
||||
assert result.error == ""
|
||||
|
||||
def test_failure_result(self) -> None:
|
||||
result = AuthResult(success=False, error="Invalid password")
|
||||
assert result.success is False
|
||||
assert result.error == "Invalid password"
|
||||
assert result.access_token == ""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
result = AuthResult(success=True)
|
||||
assert result.user_id == ""
|
||||
assert result.access_token == ""
|
||||
assert result.device_id == ""
|
||||
assert result.homeserver == ""
|
||||
|
||||
|
||||
class TestWhoAmIResult:
|
||||
"""Tests for the WhoAmIResult dataclass."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
result = WhoAmIResult(
|
||||
valid=True, user_id="@test:matrix.org", device_id="DEV1"
|
||||
)
|
||||
assert result.valid is True
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
result = WhoAmIResult(valid=False, error="M_UNKNOWN_TOKEN: Unrecognised")
|
||||
assert result.valid is False
|
||||
|
||||
|
||||
class TestPasswordLoginHTTP:
|
||||
"""Tests for _password_login — HTTP interactions are mocked."""
|
||||
|
||||
def test_successful_login(self) -> None:
|
||||
mock_response = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"access_token": "syt_test123",
|
||||
"device_id": "HERMES01",
|
||||
"home_server": "matrix.org",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value=mock_response
|
||||
) as mock_post:
|
||||
result = _password_login(
|
||||
"https://matrix.org", "@bot:matrix.org", "secret"
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.user_id == "@bot:matrix.org"
|
||||
assert result.access_token == "syt_test123"
|
||||
assert result.device_id == "HERMES01"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_login_failure(self) -> None:
|
||||
mock_response = {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "Invalid password",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value=mock_response
|
||||
):
|
||||
result = _password_login(
|
||||
"https://matrix.org", "@bot:matrix.org", "wrong"
|
||||
)
|
||||
assert result.success is False
|
||||
assert "M_FORBIDDEN" in result.error
|
||||
|
||||
def test_login_network_error(self) -> None:
|
||||
mock_response = {
|
||||
"errcode": "M_CONNECTION_ERROR",
|
||||
"error": "Connection refused",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value=mock_response
|
||||
):
|
||||
result = _password_login(
|
||||
"https://down.example.com", "@bot:down.example.com", "pass"
|
||||
)
|
||||
assert result.success is False
|
||||
assert "M_CONNECTION_ERROR" in result.error
|
||||
|
||||
|
||||
class TestValidateTokenHTTP:
|
||||
"""Tests for _validate_token — HTTP interactions are mocked."""
|
||||
|
||||
def test_valid_token(self) -> None:
|
||||
mock_response = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"device_id": "HERMES01",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._get_json", return_value=mock_response
|
||||
):
|
||||
result = _validate_token("https://matrix.org", "syt_valid")
|
||||
assert result.valid is True
|
||||
assert result.user_id == "@bot:matrix.org"
|
||||
|
||||
def test_invalid_token(self) -> None:
|
||||
mock_response = {
|
||||
"errcode": "M_UNKNOWN_TOKEN",
|
||||
"error": "Unrecognised access token",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._get_json", return_value=mock_response
|
||||
):
|
||||
result = _validate_token("https://matrix.org", "syt_bad")
|
||||
assert result.valid is False
|
||||
assert "M_UNKNOWN_TOKEN" in result.error
|
||||
|
||||
|
||||
class TestLoginFunction:
|
||||
"""Tests for the top-level login() function."""
|
||||
|
||||
def test_login_with_valid_token(self) -> None:
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._validate_token",
|
||||
return_value=WhoAmIResult(valid=True, user_id="@bot:matrix.org"),
|
||||
):
|
||||
result = login(
|
||||
homeserver="https://matrix.org", access_token="syt_good"
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.access_token == "syt_good"
|
||||
|
||||
def test_login_with_invalid_token_no_password(self) -> None:
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._validate_token",
|
||||
return_value=WhoAmIResult(valid=False, error="Bad token"),
|
||||
):
|
||||
result = login(
|
||||
homeserver="https://matrix.org", access_token="syt_bad"
|
||||
)
|
||||
# Token invalid and no password → falls through to error
|
||||
assert result.success is False
|
||||
|
||||
def test_login_with_password_no_user_id(self) -> None:
|
||||
result = login(homeserver="https://matrix.org", password="secret")
|
||||
assert result.success is False
|
||||
assert "MATRIX_USER_ID" in result.error
|
||||
|
||||
def test_login_no_credentials(self) -> None:
|
||||
result = login(homeserver="https://matrix.org")
|
||||
assert result.success is False
|
||||
assert "No valid authentication method" in result.error
|
||||
|
||||
|
||||
class TestLoginFromConfig:
|
||||
"""Tests for login_from_config with session persistence."""
|
||||
|
||||
def test_with_access_token_valid(self) -> None:
|
||||
cfg = MatrixConfig(
|
||||
homeserver="https://matrix.org",
|
||||
access_token="syt_valid",
|
||||
)
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._validate_token",
|
||||
return_value=WhoAmIResult(valid=True, user_id="@bot:matrix.org"),
|
||||
):
|
||||
result = login_from_config(cfg)
|
||||
assert result.success is True
|
||||
assert result.access_token == "syt_valid"
|
||||
|
||||
def test_password_login_saves_session(self, tmp_path: Path) -> None:
|
||||
cfg = MatrixConfig(
|
||||
homeserver="https://matrix.org",
|
||||
user_id="@bot:matrix.org",
|
||||
password="secret",
|
||||
session_dir=str(tmp_path),
|
||||
)
|
||||
login_mock = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"access_token": "syt_new",
|
||||
"device_id": "DEV1",
|
||||
}
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value=login_mock
|
||||
):
|
||||
result = login_from_config(cfg)
|
||||
assert result.success is True
|
||||
|
||||
# Session should have been persisted
|
||||
session_file = tmp_path / "@bot_matrix.org.json"
|
||||
assert session_file.exists()
|
||||
data = json.loads(session_file.read_text())
|
||||
assert data["access_token"] == "syt_new"
|
||||
|
||||
def test_loads_session_when_no_token(self, tmp_path: Path) -> None:
|
||||
# Pre-create a session file
|
||||
session_dir = tmp_path / "matrix_sessions"
|
||||
session_dir.mkdir(parents=True)
|
||||
session_data = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"access_token": "syt_saved",
|
||||
"device_id": "DEV1",
|
||||
"homeserver": "https://matrix.org",
|
||||
}
|
||||
(session_dir / "@bot_matrix.org.json").write_text(
|
||||
json.dumps(session_data)
|
||||
)
|
||||
|
||||
cfg = MatrixConfig(
|
||||
homeserver="https://matrix.org",
|
||||
user_id="@bot:matrix.org",
|
||||
session_dir=str(session_dir),
|
||||
)
|
||||
whoami_mock = WhoAmIResult(valid=True, user_id="@bot:matrix.org")
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._validate_token", return_value=whoami_mock
|
||||
):
|
||||
result = login_from_config(cfg)
|
||||
assert result.success is True
|
||||
assert result.access_token == "syt_saved"
|
||||
|
||||
def test_session_invalid_falls_through_to_password(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
# Pre-create a stale session file
|
||||
session_dir = tmp_path / "matrix_sessions"
|
||||
session_dir.mkdir(parents=True)
|
||||
session_data = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"access_token": "syt_stale",
|
||||
}
|
||||
(session_dir / "@bot_matrix.org.json").write_text(
|
||||
json.dumps(session_data)
|
||||
)
|
||||
|
||||
cfg = MatrixConfig(
|
||||
homeserver="https://matrix.org",
|
||||
user_id="@bot:matrix.org",
|
||||
password="secret",
|
||||
session_dir=str(session_dir),
|
||||
)
|
||||
# Session token is invalid → removed → password login used
|
||||
whoami_stale = WhoAmIResult(valid=False, error="Expired")
|
||||
login_mock = {
|
||||
"user_id": "@bot:matrix.org",
|
||||
"access_token": "syt_fresh",
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._validate_token", side_effect=[whoami_stale]
|
||||
):
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value=login_mock
|
||||
):
|
||||
result = login_from_config(cfg)
|
||||
assert result.success is True
|
||||
assert result.access_token == "syt_fresh"
|
||||
|
||||
|
||||
class TestSessionPersistence:
|
||||
"""Tests for save_session, load_session, remove_session."""
|
||||
|
||||
def test_save_and_load_session(self, tmp_path: Path) -> None:
|
||||
cfg = MatrixConfig(
|
||||
user_id="@test:matrix.org", session_dir=str(tmp_path)
|
||||
)
|
||||
auth = AuthResult(
|
||||
success=True,
|
||||
user_id="@test:matrix.org",
|
||||
access_token="syt_test",
|
||||
device_id="DEV99",
|
||||
homeserver="https://matrix.org",
|
||||
)
|
||||
|
||||
path = save_session(auth, cfg)
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
|
||||
loaded = load_session(cfg)
|
||||
assert loaded is not None
|
||||
assert loaded.success is True
|
||||
assert loaded.access_token == "syt_test"
|
||||
assert loaded.device_id == "DEV99"
|
||||
|
||||
def test_load_nonexistent_session(self) -> None:
|
||||
cfg = MatrixConfig(
|
||||
user_id="@nobody:matrix.org",
|
||||
session_dir="/tmp/nonexistent_dir_xyz",
|
||||
)
|
||||
result = load_session(cfg)
|
||||
assert result is None
|
||||
|
||||
def test_remove_session(self, tmp_path: Path) -> None:
|
||||
cfg = MatrixConfig(
|
||||
user_id="@test:matrix.org", session_dir=str(tmp_path)
|
||||
)
|
||||
auth = AuthResult(
|
||||
success=True,
|
||||
user_id="@test:matrix.org",
|
||||
access_token="syt_test",
|
||||
)
|
||||
save_session(auth, cfg)
|
||||
|
||||
# Verify it exists
|
||||
assert load_session(cfg) is not None
|
||||
|
||||
# Remove
|
||||
assert remove_session(cfg) is True
|
||||
assert load_session(cfg) is None
|
||||
|
||||
def test_remove_nonexistent_session(self) -> None:
|
||||
cfg = MatrixConfig(
|
||||
user_id="@nobody:matrix.org",
|
||||
session_dir="/tmp/nonexistent_dir",
|
||||
)
|
||||
assert remove_session(cfg) is False
|
||||
|
||||
def test_save_failure_result_is_none(self) -> None:
|
||||
cfg = MatrixConfig()
|
||||
auth = AuthResult(success=False, error="No credentials")
|
||||
result = save_session(auth, cfg)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""Tests for the logout function."""
|
||||
|
||||
def test_logout_success(self) -> None:
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json", return_value={}
|
||||
):
|
||||
result = logout("https://matrix.org", "syt_test")
|
||||
assert result is True
|
||||
|
||||
def test_logout_failure(self) -> None:
|
||||
with mock.patch(
|
||||
"matrix_hermes.auth._post_json",
|
||||
return_value={"errcode": "M_UNKNOWN", "error": "Bad"},
|
||||
):
|
||||
result = logout("https://matrix.org", "syt_test")
|
||||
assert result is False
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for matrix_hermes.cli module."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from matrix_hermes.cli import (
|
||||
_mask_token,
|
||||
build_parser,
|
||||
cmd_check,
|
||||
cmd_login,
|
||||
cmd_logout,
|
||||
cmd_show_config,
|
||||
cmd_whoami,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestMaskToken:
|
||||
"""Tests for _mask_token helper."""
|
||||
|
||||
def test_short_token(self):
|
||||
assert _mask_token("abc") == "***"
|
||||
|
||||
def test_long_token(self):
|
||||
result = _mask_token("syt_abcdefghijklmnopqrstuvwxyz")
|
||||
assert result.startswith("syt_ab")
|
||||
assert result.endswith("uvwxyz")
|
||||
assert "..." in result
|
||||
|
||||
def test_exact_length(self):
|
||||
"""12-char token with visible=6: len<=12 means fully masked."""
|
||||
result = _mask_token("123456789012")
|
||||
assert result == "************"
|
||||
|
||||
def test_just_over_threshold(self):
|
||||
"""13-char token with visible=6: len>12 means partial mask."""
|
||||
result = _mask_token("1234567890123")
|
||||
assert result == "123456...890123"
|
||||
|
||||
|
||||
class TestParser:
|
||||
"""Tests for CLI argument parser."""
|
||||
|
||||
def test_build_parser(self):
|
||||
parser = build_parser()
|
||||
assert "login" in parser._subparsers._group_actions[0].choices
|
||||
assert "whoami" in parser._subparsers._group_actions[0].choices
|
||||
assert "check" in parser._subparsers._group_actions[0].choices
|
||||
assert "show-config" in parser._subparsers._group_actions[0].choices
|
||||
|
||||
def test_no_args_shows_help(self, capsys):
|
||||
"""Running with no args shows help."""
|
||||
result = main([])
|
||||
# Should not crash
|
||||
captured = capsys.readouterr()
|
||||
assert "usage" in captured.out.lower() or result == 0
|
||||
|
||||
|
||||
class TestCmdCheck:
|
||||
"""Tests for 'check' command."""
|
||||
|
||||
def test_not_configured(self, clean_env, capsys):
|
||||
"""When no Matrix vars are set, check should fail."""
|
||||
args = build_parser().parse_args(["check"])
|
||||
result = cmd_check(args)
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "not configured" in captured.out.lower()
|
||||
|
||||
def test_configured(self, mock_env, capsys):
|
||||
"""When Matrix vars are set, check should succeed."""
|
||||
args = build_parser().parse_args(["check"])
|
||||
result = cmd_check(args)
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "valid" in captured.out.lower()
|
||||
|
||||
|
||||
class TestCmdShowConfig:
|
||||
"""Tests for 'show-config' command."""
|
||||
|
||||
def test_output_format(self, mock_env, capsys):
|
||||
"""show-config outputs env var format."""
|
||||
args = build_parser().parse_args(["show-config"])
|
||||
result = cmd_show_config(args)
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "MATRIX_HOMESERVER=" in captured.out
|
||||
assert "MATRIX_ACCESS_TOKEN=" in captured.out
|
||||
|
||||
def test_redacted(self, mock_env, capsys):
|
||||
"""show-config --redact masks sensitive values."""
|
||||
args = build_parser().parse_args(["show-config", "--redact"])
|
||||
result = cmd_show_config(args)
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
# Token should be masked
|
||||
assert "syt_test_token" not in captured.out
|
||||
assert "syt_test" in captured.out or "..." in captured.out
|
||||
|
||||
|
||||
class TestCmdLogin:
|
||||
"""Tests for 'login' command."""
|
||||
|
||||
def test_no_homeserver(self, clean_env, capsys):
|
||||
"""Login fails without homeserver."""
|
||||
args = build_parser().parse_args(["login"])
|
||||
result = cmd_login(args)
|
||||
assert result == 1
|
||||
|
||||
def test_with_valid_token(self, mock_env, capsys):
|
||||
"""Login with valid token prints success."""
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(
|
||||
{"user_id": "@bot:example.org", "device_id": "DEV1"}
|
||||
).encode()
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
args = build_parser().parse_args(["login"])
|
||||
result = cmd_login(args)
|
||||
assert result == 0
|
||||
|
||||
def test_with_bad_credentials(self, mock_env, capsys):
|
||||
"""Login with bad credentials prints error."""
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(
|
||||
{"errcode": "M_FORBIDDEN", "error": "Bad password"}
|
||||
).encode()
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
# Override with password-based login (no valid token)
|
||||
import os
|
||||
|
||||
os.environ["MATRIX_ACCESS_TOKEN"] = ""
|
||||
os.environ["MATRIX_PASSWORD"] = "wrong"
|
||||
|
||||
args = build_parser().parse_args(["login"])
|
||||
result = cmd_login(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
class TestCmdWhoami:
|
||||
"""Tests for 'whoami' command."""
|
||||
|
||||
def test_no_homeserver(self, clean_env, capsys):
|
||||
"""whoami fails without homeserver."""
|
||||
args = build_parser().parse_args(["whoami"])
|
||||
result = cmd_whoami(args)
|
||||
assert result == 1
|
||||
|
||||
def test_valid(self, mock_env, capsys):
|
||||
"""whoami with valid token."""
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(
|
||||
{"user_id": "@bot:example.org"}
|
||||
).encode()
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
args = build_parser().parse_args(["whoami"])
|
||||
result = cmd_whoami(args)
|
||||
assert result == 0
|
||||
|
||||
def test_invalid(self, mock_env, capsys):
|
||||
"""whoami with invalid token."""
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(
|
||||
{"errcode": "M_UNKNOWN_TOKEN", "error": "Unrecognised"}
|
||||
).encode()
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
args = build_parser().parse_args(["whoami"])
|
||||
result = cmd_whoami(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
class TestCmdLogout:
|
||||
"""Tests for 'logout' command."""
|
||||
|
||||
def test_no_homeserver(self, clean_env, capsys):
|
||||
"""logout fails without homeserver."""
|
||||
args = build_parser().parse_args(["logout"])
|
||||
result = cmd_logout(args)
|
||||
assert result == 1
|
||||
|
||||
def test_success(self, mock_env, capsys):
|
||||
"""logout with valid token."""
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({}).encode()
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
args = build_parser().parse_args(["logout"])
|
||||
result = cmd_logout(args)
|
||||
assert result == 0
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Test Matrix configuration loading from env vars and files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from matrix_hermes.config import MatrixConfig, _parse_csv
|
||||
|
||||
|
||||
class TestParseCSV:
|
||||
"""Tests for the CSV parsing helper."""
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert _parse_csv("") == []
|
||||
|
||||
def test_single_value(self) -> None:
|
||||
assert _parse_csv("alice") == ["alice"]
|
||||
|
||||
def test_multiple_values(self) -> None:
|
||||
assert _parse_csv("alice, bob , charlie") == ["alice", "bob", "charlie"]
|
||||
|
||||
def test_whitespace_only(self) -> None:
|
||||
assert _parse_csv(" , , ") == []
|
||||
|
||||
|
||||
class TestMatrixConfigDefaults:
|
||||
"""Tests for default config values."""
|
||||
|
||||
def test_empty_config(self) -> None:
|
||||
cfg = MatrixConfig()
|
||||
assert cfg.homeserver == ""
|
||||
assert cfg.access_token == ""
|
||||
assert cfg.password == ""
|
||||
|
||||
def test_is_configured_false(self) -> None:
|
||||
cfg = MatrixConfig()
|
||||
assert cfg.is_configured() is False
|
||||
|
||||
def test_is_configured_with_homeserver_and_token(self) -> None:
|
||||
cfg = MatrixConfig(homeserver="https://matrix.org", access_token="fake-token")
|
||||
assert cfg.is_configured() is True
|
||||
|
||||
def test_is_configured_with_homeserver_and_password(self) -> None:
|
||||
cfg = MatrixConfig(
|
||||
homeserver="https://matrix.org",
|
||||
user_id="@test:matrix.org",
|
||||
password="secret",
|
||||
)
|
||||
assert cfg.is_configured() is True
|
||||
|
||||
def test_validate_empty_config(self) -> None:
|
||||
cfg = MatrixConfig()
|
||||
errors = cfg.validate()
|
||||
assert len(errors) > 0
|
||||
assert any("MATRIX_HOMESERVER" in e for e in errors)
|
||||
|
||||
def test_validate_missing_auth(self) -> None:
|
||||
cfg = MatrixConfig(homeserver="https://matrix.org")
|
||||
errors = cfg.validate()
|
||||
assert any("ACCESS_TOKEN" in e or "PASSWORD" in e for e in errors)
|
||||
|
||||
def test_resolved_session_dir_default(self) -> None:
|
||||
cfg = MatrixConfig()
|
||||
assert cfg.resolved_session_dir == str(
|
||||
Path.home() / ".hermes" / "matrix_sessions"
|
||||
)
|
||||
|
||||
def test_resolved_session_dir_custom(self) -> None:
|
||||
cfg = MatrixConfig(session_dir="/tmp/matrix_test")
|
||||
assert cfg.resolved_session_dir == "/tmp/matrix_test"
|
||||
|
||||
|
||||
class TestFromEnv:
|
||||
"""Tests for env var loading."""
|
||||
|
||||
def test_loads_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://example.com")
|
||||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")
|
||||
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.com")
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
|
||||
cfg = MatrixConfig.from_env()
|
||||
assert cfg.homeserver == "https://example.com"
|
||||
assert cfg.access_token == "syt_abc123"
|
||||
assert cfg.user_id == "@bot:example.com"
|
||||
assert cfg.require_mention is False
|
||||
|
||||
def test_encryption_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
|
||||
cfg = MatrixConfig.from_env()
|
||||
assert cfg.encryption is True
|
||||
|
||||
def test_allowed_users(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@a:example.com, @b:example.com")
|
||||
cfg = MatrixConfig.from_env()
|
||||
assert cfg.allowed_users == ["@a:example.com", "@b:example.com"]
|
||||
|
||||
def test_auto_thread_default(self) -> None:
|
||||
cfg = MatrixConfig.from_env()
|
||||
assert cfg.auto_thread is True
|
||||
|
||||
def test_require_mention_default(self) -> None:
|
||||
cfg = MatrixConfig.from_env()
|
||||
assert cfg.require_mention is True
|
||||
Reference in New Issue
Block a user