Implement real Matrix notification for coe-mirror

Replace stub notify() with full Synapse integration targeting General room.
- Add _send_matrix(), _get_matrix_token(), _matrix_login() helpers
- Implement token cache with 30-min refresh threshold and 401 retry
- Fetch credentials via Infisical (MATRIX_USER, MATRIX_PASSWORD from matrix/)
- Add MATRIX_* env overrides for testing (homeserver, room, token cache path)
- Non-fatal failures: Matrix outage logs warning, does not break mirror run
- Add 5 comprehensive test cases covering success, failure, 401 refresh, env override, chmod 600
- All 18 tests passing
- Update README and notes.md with implementation details
This commit is contained in:
n0mad1k
2026-06-14 14:36:14 -04:00
parent 745844f721
commit 24d062fc5d
3 changed files with 408 additions and 9 deletions
+162 -8
View File
@@ -4,14 +4,17 @@ Fetches all repos owned by Nightmare_Eclipse and stores per-commit zip snapshots
Runs unattended via systemd timer twice daily. Local staging when pCloud unavailable.
"""
import json
import logging
import logging.handlers
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fcntl import flock, LOCK_EX, LOCK_NB
from typing import Optional
@@ -34,11 +37,22 @@ RETRY_BACKOFF = [0, 5, 30] # seconds between attempts; len = max attempts
USER_AGENT = "coe-mirror/1.0"
REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
# Matrix configuration
MATRIX_HOMESERVER = "https://m.example.org"
MATRIX_ROOM = "!REDACTEDROOM:m.example.org" # General
MATRIX_TOKEN_CACHE = LOG_DIR / "matrix-token.json"
MATRIX_TIMEOUT = 8
MATRIX_REFRESH_THRESHOLD_SECONDS = 1800 # refresh if <30 min left
# Allow env var overrides for testing and deployment flexibility
if env_pcloud := os.getenv("COE_MIRROR_PCLOUD_TARGET"):
PCLOUD_TARGET = Path(env_pcloud)
if env_pending := os.getenv("COE_MIRROR_PENDING_DIR"):
PENDING_DIR = Path(env_pending)
if env_hs := os.getenv("COE_MIRROR_MATRIX_HOMESERVER"):
MATRIX_HOMESERVER = env_hs
if env_room := os.getenv("COE_MIRROR_MATRIX_ROOM"):
MATRIX_ROOM = env_room
logger: logging.Logger = None # Initialized in setup_logging()
@@ -296,20 +310,160 @@ def flush_pending(pcloud_healthy: bool) -> list:
return moved
def _read_token_cache() -> Optional[dict]:
"""Return cached token dict or None on any failure. Never raises."""
try:
if not MATRIX_TOKEN_CACHE.exists():
return None
with open(MATRIX_TOKEN_CACHE) as f:
data = json.load(f)
# Shape check
if "access_token" in data and "expires_at" in data:
return data
except Exception as exc:
logger.debug("token cache read failed: %s", exc)
return None
def _write_token_cache(access_token: str, expires_at_iso: str) -> None:
"""Write token cache with 0600 permissions. Never raises."""
try:
MATRIX_TOKEN_CACHE.parent.mkdir(parents=True, exist_ok=True)
payload = {"access_token": access_token, "expires_at": expires_at_iso}
# Write then chmod for atomicity-ish behavior
tmp = MATRIX_TOKEN_CACHE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, MATRIX_TOKEN_CACHE)
except Exception as exc:
logger.warning("token cache write failed: %s", exc)
def _fetch_creds() -> Optional[tuple]:
"""Fetch (user, password) from Infisical via creds. Return None on failure."""
try:
user = subprocess.run(
["creds", "get", "MATRIX_USER", "matrix"],
capture_output=True, text=True, timeout=5
)
pw = subprocess.run(
["creds", "get", "MATRIX_PASSWORD", "matrix"],
capture_output=True, text=True, timeout=5
)
if user.returncode == 0 and pw.returncode == 0:
u, p = user.stdout.strip(), pw.stdout.strip()
if u and p:
return u, p
logger.warning("creds returned non-zero for MATRIX_USER/MATRIX_PASSWORD")
except FileNotFoundError:
logger.warning("creds binary not found on PATH")
except subprocess.TimeoutExpired:
logger.warning("creds timed out fetching Matrix credentials")
except Exception as exc:
logger.warning("creds fetch failed: %s", exc)
return None
def _matrix_login(session: requests.Session) -> Optional[str]:
"""Login via password, cache token, return access_token. None on failure."""
creds = _fetch_creds()
if not creds:
return None
user, password = creds
url = f"{MATRIX_HOMESERVER}/_matrix/client/r0/login"
body = {"type": "m.login.password", "user": user, "password": password}
try:
resp = session.post(url, json=body, timeout=MATRIX_TIMEOUT)
if resp.status_code != 200:
logger.warning("matrix login failed: HTTP %d", resp.status_code)
return None
data = resp.json()
token = data.get("access_token")
expires_in_ms = data.get("expires_in_ms", 0)
if not token:
logger.warning("matrix login returned no access_token")
return None
expires_at = datetime.now(timezone.utc) + timedelta(milliseconds=expires_in_ms)
_write_token_cache(token, expires_at.isoformat())
return token
except requests.RequestException as exc:
logger.warning("matrix login network error: %s", exc)
except Exception as exc:
logger.warning("matrix login unexpected error: %s", exc)
return None
def _get_matrix_token(session: requests.Session, force_refresh: bool = False) -> Optional[str]:
"""Return a valid token. Read cache → check expiry → login if needed."""
if not force_refresh:
cached = _read_token_cache()
if cached:
try:
expires_at = datetime.fromisoformat(cached["expires_at"])
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds()
if remaining > MATRIX_REFRESH_THRESHOLD_SECONDS:
return cached["access_token"]
except Exception as exc:
logger.debug("cache expiry parse failed: %s", exc)
return _matrix_login(session)
def _send_matrix(message: str) -> bool:
"""Send a Matrix message to MATRIX_ROOM. Return True on success.
Non-fatal: any failure logs WARN and returns False. Never raises.
Implements one 401 retry with token refresh.
"""
session = requests.Session()
session.headers.update({"User-Agent": USER_AGENT})
encoded_room = quote(MATRIX_ROOM, safe="")
url = f"{MATRIX_HOMESERVER}/_matrix/client/r0/rooms/{encoded_room}/send/m.room.message"
body = {"msgtype": "m.text", "body": message}
for attempt in (1, 2):
token = _get_matrix_token(session, force_refresh=(attempt == 2))
if not token:
logger.warning("matrix send aborted: no token (attempt %d)", attempt)
return False
try:
resp = session.post(
url,
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=MATRIX_TIMEOUT,
)
if resp.status_code == 200:
logger.info("matrix sent: %s", message[:80])
return True
if resp.status_code == 401 and attempt == 1:
logger.info("matrix 401 on send; refreshing token and retrying")
continue
logger.warning("matrix send failed: HTTP %d body=%s",
resp.status_code, resp.text[:200])
return False
except requests.RequestException as exc:
logger.warning("matrix send network error: %s", exc)
return False
except Exception as exc:
logger.warning("matrix send unexpected error: %s", exc)
return False
return False
def notify(summary: list) -> None:
"""Stub notification. Logs intention to notify Matrix when service is back."""
"""Build a Matrix message and POST it. Non-fatal."""
if not summary:
return
repos = [f"{repo}@{short_sha}" for repo, short_sha, _ in summary]
repos_str = ", ".join(repos)
logger.info(
"notify(matrix-stub): coe-mirror: %d new from Nightmare_Eclipse: %s",
len(repos), repos_str
)
logger.debug(
"TODO: Implement real Matrix notification via ~/tools/devtrack/scripts/matrix_alerts.py when service is available"
text = (
f"coe-mirror: {len(repos)} new from Nightmare_Eclipse: {repos_str}"
)
# Always log the intended message so behavior is visible even when Matrix is down
logger.info("notify: %s", text)
_send_matrix(text)
def main() -> int: