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:
+162
-8
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user