ForcePilot/backend/package/yuxi/channels/adapters/matrix/monitor.py
Kris 2063145ce1 feat(matrix): 新增完整的Matrix适配器工具集
新增了20+个Matrix相关工具模块,包含线程处理、自动加入、消息去重、配置管理、安全策略、媒体处理、命令解析等功能,完善Matrix适配器基础能力
2026-05-12 00:45:54 +08:00

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}")