from __future__ import annotations import asyncio import json import logging from pathlib import Path from .config import _apply_env_overrides, _dict_to_account from .constants import SYNC_LOOP_SLEEP_MS from .dedupe import DedupeCache from .dm_tracker import DmTracker from .monitor import normalize_matrix_event from .rate_limiter import MatrixRateLimiter from .utils import build_dedupe_key, get_nio logger = logging.getLogger(__name__) SYNC_FILTER = { "room": { "timeline": { "types": [ "m.room.message", "m.room.encrypted", "m.reaction", "m.room.member", "m.room.name", "m.room.topic", ], "limit": 50, }, "ephemeral": {"types": ["m.typing", "m.receipt"]}, "account_data": {"types": ["m.direct"]}, }, } async def start(ctx) -> object: nio = get_nio() queue = ctx.queue cancel_event = ctx.cancel_event account_data = ctx.config.get("accounts", {}).get(ctx.account_id, {}) account = _dict_to_account(account_data) account = _apply_env_overrides(account) store_path = account_data.get("store_path") or f"data/matrix_store/{account.account_id}" client_config = nio.AsyncClientConfig( store_sync_tokens=True, encryption_enabled=account.encryption, ) client = nio.AsyncClient( homeserver=account.homeserver, user=account.user_id, device_id=account.device_id, store_path=store_path, config=client_config, ) client.access_token = account.access_token resp = await client.whoami() logger.info( "Matrix whoami: user=%s device=%s", resp.user_id, resp.device_id, ) account.device_id = account.device_id or resp.device_id ctx._matrix_client = client _dedupe = DedupeCache() _rate_limiter = MatrixRateLimiter() ctx._matrix_dedupe = _dedupe ctx._matrix_rate_limiter = _rate_limiter dm_tracker = DmTracker() sync_filter = dict(SYNC_FILTER) sync_filter["room"]["timeline"]["limit"] = account.initial_sync_limit sync_token = account_data.get("sync_token") try: async for response in client.sync_forever( since=sync_token, sync_filter=sync_filter, loop_sleep_time=SYNC_LOOP_SLEEP_MS, ): if cancel_event.is_set(): logger.info("Matrix sync cancelled for account %s", account.account_id) break if isinstance(response, nio.SyncResponse): sync_token = response.next_batch _persist_sync_token(ctx, sync_token) if isinstance(response, nio.InviteEvent): _handle_invite(response, dm_tracker, ctx) events = _extract_sync_events(response) for event in events: dedupe_key = build_dedupe_key(account.account_id, event.event_id) if dedupe_key in _dedupe: continue _dedupe.add(dedupe_key) msg = normalize_matrix_event( event=event, own_user_id=account.user_id, account_id=account.account_id, dm_tracker=dm_tracker, ) if msg is not None: await queue.put(msg) except Exception: logger.exception("Matrix sync loop error for account %s", account.account_id) finally: ctx._matrix_client = None await client.close() return client def _handle_invite(response, dm_tracker: DmTracker, ctx) -> None: event = response room_id = getattr(event, "room_id", None) sender = getattr(getattr(event, "sender", None), "user_id", getattr(event, "sender", None)) if room_id and sender: dm_tracker.mark_candidate_dm(room_id, sender) logger.info("Received invite to room %s from %s", room_id, sender) if not room_id: return accounts = ctx.config.get("accounts", {}) acct_data = accounts.get(ctx.account_id, {}) auto_join = acct_data.get("auto_join", "off") if auto_join == "off": return if auto_join == "all" or (auto_join == "allowlist" and sender in acct_data.get("auto_join_allowlist", [])): client = getattr(ctx, "_matrix_client", None) if client: asyncio.ensure_future(client.join(room_id)) logger.info("Auto-joining room %s (auto_join=%s)", room_id, auto_join) def _extract_sync_events(response) -> list: if hasattr(response, "rooms") and response.rooms: events = [] join = getattr(response.rooms, "join", {}) or {} for room_id, room_info in join.items(): timeline = getattr(room_info, "timeline", None) if timeline and hasattr(timeline, "events"): events.extend(timeline.events) return events if hasattr(response, "source"): source = response.source rooms_data = source.get("rooms", {}).get("join", {}) events = [] for room_id, room_data in rooms_data.items(): timeline_events = room_data.get("timeline", {}).get("events", []) for e in timeline_events: events.append(_wrap_raw_event(e, room_id)) return events return [] def _wrap_raw_event(raw: dict, room_id: str): class _WrappedEvent: pass event = _WrappedEvent() event.source = raw event.room_id = room_id event.event_id = raw.get("event_id", "") event.sender = raw.get("sender", "") event.type = raw.get("type", "") return event async def stop(ctx) -> None: client = getattr(ctx, "_matrix_client", None) if client: try: await client.close() except Exception: logger.debug("Matrix client close error", exc_info=True) def _persist_sync_token(ctx, sync_token: str) -> None: try: config_path = getattr(ctx, "config_path", None) if not config_path: return raw = ctx.config accounts = raw.get("accounts", {}) acct = accounts.setdefault(ctx.account_id, {}) acct["sync_token"] = sync_token Path(config_path).parent.mkdir(parents=True, exist_ok=True) Path(config_path).write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8") except Exception: logger.debug("Failed to persist sync_token", exc_info=True) def resolve_gateway_auth_bypass_paths(config: dict) -> list[str]: return [] async def login_with_qr_start(account_id: str | None = None, *, force=False, timeout_ms=None) -> dict: raise NotImplementedError("Matrix does not support QR login") async def login_with_qr_wait(account_id=None, *, timeout_ms=None, current_qr_data_url=None) -> dict: raise NotImplementedError("Matrix does not support QR login") async def logout_account(ctx) -> dict: nio = get_nio() account_data = ctx.config.get("accounts", {}).get(ctx.account_id, {}) account = _dict_to_account(account_data) account = _apply_env_overrides(account) client = nio.AsyncClient( homeserver=account.homeserver, user=account.user_id, ) client.access_token = account.access_token try: await client.logout() return {"ok": True} except Exception as e: logger.warning("Matrix logout failed: %s", e) return {"ok": False, "error": str(e)} finally: await client.close()