Files
OpenOPC/opc/channels/matrix.py
T
2026-07-01 17:56:31 +08:00

169 lines
6.6 KiB
Python

from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from loguru import logger
from opc.channels.provider_base import OptionalDependencyChannel
from opc.core.models import SystemMessage
class MatrixChannel(OptionalDependencyChannel):
name = "matrix"
required_package = "nio"
delivery_mode = "polling"
def __init__(self, config: Any, bus: Any):
super().__init__(config, bus)
self.client: Any = None
self._sync_task: asyncio.Task[Any] | None = None
def get_required_config_fields(self) -> list[str]:
return ["homeserver", "access_token", "user_id"]
def normalize_event(self, payload: dict[str, Any]) -> dict[str, Any]:
content = payload.get("content", {})
relates_to = dict(content.get("m.relates_to", {}) or {})
thread_id = str(relates_to.get("event_id", "") or relates_to.get("m.in_reply_to", {}).get("event_id", "") or "")
return {
"sender_id": str(payload.get("sender", "")),
"chat_id": str(payload.get("room_id", "")),
"content": str(content.get("body", "") or ""),
"thread_id": thread_id,
"reply_to": str(payload.get("event_id", "") or ""),
"metadata": {
"matrix": {
"event_id": str(payload.get("event_id", "") or ""),
"relates_to": relates_to,
}
},
}
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
sender_id = str(normalized.get("sender_id", "") or "")
if not sender_id or sender_id == self.config.user_id:
return False
return self.is_allowed(sender_id)
async def start(self) -> None:
await super().start()
from nio import AsyncClient, AsyncClientConfig, InviteEvent, RoomMessageText
store_path = Path(".opc") / "matrix-store"
store_path.mkdir(parents=True, exist_ok=True)
self.client = AsyncClient(
homeserver=self.config.homeserver,
user=self.config.user_id,
store_path=str(store_path),
config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled),
)
self.client.user_id = self.config.user_id
self.client.access_token = self.config.access_token
self.client.device_id = self.config.device_id
self.client.add_event_callback(self._on_text_message, RoomMessageText)
self.client.add_event_callback(self._on_room_invite, InviteEvent)
self._sync_task = asyncio.create_task(self._sync_loop())
async def stop(self) -> None:
self.mark_stopped()
if self.client is not None:
try:
self.client.stop_sync_forever()
except Exception:
pass
if self._sync_task is not None:
self._sync_task.cancel()
try:
await self._sync_task
except asyncio.CancelledError:
pass
self._sync_task = None
if self.client is not None:
try:
await self.client.close()
except Exception:
logger.exception("matrix client close failed")
self.client = None
await super().stop()
async def _sync_loop(self) -> None:
assert self.client is not None
try:
await self.client.sync_forever(timeout=30000, full_state=True)
except asyncio.CancelledError:
raise
except Exception as exc:
self.set_last_error(exc)
raise
async def _on_room_invite(self, room: Any, event: Any) -> None:
if self.client is None:
return
sender = str(getattr(event, "sender", "") or "")
if not self.is_allowed(sender):
return
try:
await self.client.join(room.room_id)
except Exception:
logger.exception("matrix join failed for {}", room.room_id)
async def _on_text_message(self, room: Any, event: Any) -> None:
content = getattr(event, "source", {}).get("content", {})
payload = {
"sender": str(getattr(event, "sender", "") or ""),
"room_id": str(getattr(room, "room_id", "") or ""),
"event_id": str(getattr(event, "event_id", "") or ""),
"content": content,
}
await self.publish_normalized(self.normalize_event(payload))
async def send(self, message: SystemMessage) -> None:
await super().send(message)
if self.client is None:
logger.warning("matrix client not connected")
return
metadata = dict(message.metadata or {})
room_id = str(metadata.get("chat_id") or message.session_id or "")
content: dict[str, Any] = {
"msgtype": "m.text",
"body": message.content or "",
}
relates_to = self._build_thread_relates_to(metadata)
if relates_to:
content["m.relates_to"] = relates_to
await self.client.room_send(room_id=room_id, message_type="m.room.message", content=content)
for attachment in list(metadata.get("attachments", []) or []):
if not isinstance(attachment, str):
continue
path = Path(attachment)
if not path.is_file():
continue
with path.open("rb") as handle:
upload_result = await self.client.upload(
handle,
content_type="application/octet-stream",
filename=path.name,
filesize=path.stat().st_size,
)
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
mxc_url = getattr(upload_response, "content_uri", None)
if not mxc_url:
continue
content = {"msgtype": "m.file", "body": path.name, "filename": path.name, "url": mxc_url}
if relates_to:
content["m.relates_to"] = relates_to
await self.client.room_send(room_id=room_id, message_type="m.room.message", content=content)
@staticmethod
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
metadata = dict(metadata or {})
thread_id = str(metadata.get("thread_id", "") or "")
reply_to = str(metadata.get("reply_to", "") or "")
if not thread_id and not reply_to:
return None
if thread_id:
return {"rel_type": "m.thread", "event_id": thread_id}
return {"m.in_reply_to": {"event_id": reply_to}}