310 lines
12 KiB
Python
310 lines
12 KiB
Python
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}")
|