from __future__ import annotations import asyncio from typing import TYPE_CHECKING, Any from collections.abc import Callable, Awaitable from datetime import datetime, UTC from nio import ( RoomMemberEvent, RoomMessageFormatted, RoomMessageMedia, RoomMessageText, SyncResponse, ) from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelType, ChatType, EventType, MessageType, ) from yuxi.utils.logging_config import logger from .dedupe import EventDeduplicator from .security import MatrixSecurityPolicy if TYPE_CHECKING: from nio import AsyncClient from yuxi.channels.adapters.matrix.adapter import MatrixAdapter _MESSAGE_SYNC_FILTER = { "room": { "timeline": {"types": ["m.room.message", "m.reaction", "m.room.encrypted"]}, "state": {"types": ["m.room.member"]}, "ephemeral": {"types": []}, "account_data": {"types": []}, } } class MatrixMonitor: def __init__(self, config: dict[str, Any], client: AsyncClient, adapter: MatrixAdapter, sync_token_store=None): self._config = config self._client = client self._adapter = adapter self._sync_token_store = sync_token_store self._message_handler: Callable[[ChannelMessage], Awaitable[None]] | None = None self._sync_task: asyncio.Task | None = None self._running = False self._deduplicator = EventDeduplicator() self._security = MatrixSecurityPolicy(config) @property def running(self) -> bool: return self._running def on_message(self, handler: Callable[[ChannelMessage], Awaitable[None]]) -> None: self._message_handler = handler async def start(self, initial_since: str | None = None) -> None: if self._sync_task and not self._sync_task.done(): return self._running = True timeout_ms = self._config.get("sync_timeout_ms", 30000) loop_sleep_ms = self._config.get("loop_sleep_ms", 100) async def _sync_loop(): retry_count = 0 max_backoff = 300 while self._running: try: await self._client.sync_forever( timeout_ms=timeout_ms, since=initial_since, loop_sleep_ms=loop_sleep_ms, sync_filter=_MESSAGE_SYNC_FILTER, ) retry_count = 0 except asyncio.CancelledError: break except Exception as e: retry_count += 1 delay = min(5 * (2 ** min(retry_count - 1, 5)), max_backoff) logger.error(f"Matrix sync_forever error, retrying in {delay}s: {e}") await asyncio.sleep(delay) self._sync_task = asyncio.create_task(_sync_loop()) self._client.add_response_callback(self._on_sync_response, SyncResponse) logger.info("Matrix monitor started (sync_forever)") async def stop(self) -> None: self._running = False try: await self._client.stop_sync_forever() except Exception: pass if self._sync_task: self._sync_task.cancel() try: await self._sync_task except asyncio.CancelledError: pass self._sync_task = None logger.info("Matrix monitor stopped") async def _on_sync_response(self, response: SyncResponse) -> None: if self._sync_token_store and response.next_batch: self._sync_token_store.save(response.next_batch) for room_id, room in response.rooms.join.items(): is_dm = self._adapter._is_dm_room(room_id) for event in room.timeline.events: if not self._message_handler: continue event_id = getattr(event, "event_id", "") if event_id and self._deduplicator.is_duplicate(event_id): logger.debug(f"Matrix duplicate event skipped: {event_id}") continue sender = getattr(event, "sender", "") if sender: if self._security.should_skip_bot(sender): logger.debug(f"Matrix bot message skipped: {sender}") continue if is_dm: if not self._security.evaluate_dm_access(sender): logger.debug(f"Matrix DM access denied: {sender}") continue else: if not self._security.evaluate_group_access(sender, room_id): logger.debug(f"Matrix group access denied: {sender} in {room_id}") continue event_type = event.source.get("type", "") if event_type == "m.reaction": msg = self._build_reaction_message(event, room_id) if msg: await self._message_handler(msg) reaction_notifications = self._config.get("reactionNotifications", "own") if reaction_notifications in ("all", "own") and sender != self._adapter.user_id: self._notify_reaction(event, room_id) continue if event_type == "m.room.encrypted": decrypted = await self._handle_encrypted_event(event, room_id) if decrypted: msg = self._adapter.normalize_inbound(decrypted) if msg: await self._message_handler(msg) continue if event_type == "m.room.member": event_content = event.source.get("content", {}) if event_content.get("membership") == "invite": await self._handle_invite_event(event, room_id) continue if isinstance(event, (RoomMessageText, RoomMessageFormatted, RoomMessageMedia)): msg = self._adapter.normalize_inbound(event) if msg: await self._message_handler(msg) chat_type = self._adapter._session.resolve_chat_type(room_id) if self._adapter._should_send_ack(chat_type): await self._adapter.send_ack_reaction( msg.identity.channel_chat_id, msg.identity.channel_message_id, ) for event in room.state: if not self._message_handler: continue if isinstance(event, RoomMemberEvent): msg = self._build_member_message(event, room_id) if msg: await self._message_handler(msg) def _build_reaction_message(self, event: Any, room_id: str) -> ChannelMessage | None: content = event.source.get("content", {}) relates_to = content.get("m.relates_to", {}) target_event_id = relates_to.get("event_id", "") reaction_key = relates_to.get("key", "") if not target_event_id or not reaction_key: return None server_ts = getattr(event, "server_timestamp", None) timestamp = datetime.fromtimestamp((server_ts / 1000) if server_ts else datetime.now(UTC).timestamp(), tz=UTC) return ChannelMessage( identity=ChannelIdentity( channel_id="matrix", channel_type=ChannelType.MATRIX, channel_user_id=event.sender, channel_chat_id=room_id, channel_message_id=event.event_id, ), event_type=EventType.MESSAGE_RECEIVED, message_type=MessageType.TEXT, chat_type=ChatType.GROUP, content=reaction_key, reply_to_message_id=target_event_id, metadata={ "room_id": room_id, "reaction_to": target_event_id, "reaction_key": reaction_key, "origin_server_ts": server_ts, }, timestamp=timestamp, ) def _build_member_message(self, event: RoomMemberEvent, room_id: str) -> ChannelMessage | None: membership = event.membership if membership == "join": event_type = EventType.MEMBER_JOINED elif membership in ("leave", "ban"): event_type = EventType.MEMBER_LEFT else: return None server_ts = getattr(event, "server_timestamp", None) timestamp = datetime.fromtimestamp((server_ts / 1000) if server_ts else datetime.now(UTC).timestamp(), tz=UTC) return ChannelMessage( identity=ChannelIdentity( channel_id="matrix", channel_type=ChannelType.MATRIX, channel_user_id=event.state_key, channel_chat_id=room_id, channel_message_id=event.event_id, ), event_type=event_type, message_type=MessageType.TEXT, chat_type=ChatType.GROUP, content=membership, metadata={ "room_id": room_id, "membership": membership, "displayname": event.content.get("displayname", ""), "origin_server_ts": server_ts, }, timestamp=timestamp, ) async def _handle_encrypted_event(self, event: Any, room_id: str) -> Any | None: from .encryption import decrypt_event try: decrypted = await decrypt_event(self._client, event, room_id) if decrypted: class _DecryptedEvent: pass decrypted_event = _DecryptedEvent() decrypted_event.sender = event.sender decrypted_event.event_id = getattr(event, "event_id", "") decrypted_event.server_timestamp = getattr(event, "server_timestamp", None) decrypted_event.source = { "content": decrypted.get("content", {}), "type": decrypted.get("type", "m.room.message"), } decrypted_event.body = decrypted.get("content", {}).get("body", "") decrypted_event.formatted_body = decrypted.get("content", {}).get("formatted_body", "") return decrypted_event except Exception as e: logger.debug(f"Matrix decrypt attempt failed for {room_id}: {e}") return None async def _handle_invite_event(self, event: Any, room_id: str) -> None: invite_state = event.source.get("content", {}).get("membership") if invite_state != "invite": return auto_accept = self._config.get("autoJoin", "off") in ("always", "invites") if not auto_accept: logger.info(f"Matrix invite received for {room_id}, auto-accept disabled") return try: await self._client.join(room_id) self._adapter._joined_rooms.add(room_id) logger.info(f"Matrix auto-joined invite room: {room_id}") except Exception as e: logger.warning(f"Matrix auto-join invite failed for {room_id}: {e}") def _notify_reaction(self, event: Any, room_id: str) -> None: content = event.source.get("content", {}) relates_to = content.get("m.relates_to", {}) target_event_id = relates_to.get("event_id", "") reaction_key = relates_to.get("key", "") sender = getattr(event, "sender", "") notification_scope = self._config.get("reactionNotifications", "own") if notification_scope == "own": target_events = self._config.get("notify_event_ids", set()) if target_event_id not in target_events: return logger.debug(f"Matrix reaction: {sender} reacted with {reaction_key} to {target_event_id} in {room_id}")