890 lines
33 KiB
Python
890 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any, ClassVar
|
|
|
|
from nio import (
|
|
AsyncClient,
|
|
DownloadResponse,
|
|
LoginError,
|
|
LoginResponse,
|
|
LocalProtocolError,
|
|
SyncError,
|
|
)
|
|
|
|
from yuxi.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.capabilities import ChannelCapabilities
|
|
from yuxi.channels.meta import ChannelMeta
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
from yuxi.channels.exceptions import (
|
|
ChannelAuthenticationError,
|
|
ChannelException,
|
|
)
|
|
from yuxi.channels.models import (
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
HealthStatus,
|
|
)
|
|
from yuxi.channels.registry import register_builtin_adapter
|
|
from yuxi.utils.datetime_utils import utc_now_naive
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
from .auto_join import should_auto_join
|
|
from .config_patch import MatrixAccountPatch, apply_patch
|
|
from .crypto import import_room_keys
|
|
from .encryption import check_crypto_store_ready, format_encryption_unavailable_error
|
|
from .group_mentions import GroupMentionStrategy
|
|
from .monitor import MatrixMonitor
|
|
from .normalizer import MatrixNormalizer
|
|
from .polls import create_poll, end_poll, send_poll_response
|
|
from .probe import probe_matrix
|
|
from .profile_sync import resolve_avatar_url
|
|
from .send import (
|
|
classify_send_error,
|
|
send_formatted_media,
|
|
send_formatted_text,
|
|
send_location,
|
|
send_reaction,
|
|
send_sticker,
|
|
send_stream_chunk,
|
|
send_thread_message,
|
|
)
|
|
from .session import MatrixSessionHelper
|
|
from .sync_store import SSRFGuard, SyncTokenStore
|
|
|
|
|
|
@register_builtin_adapter
|
|
class MatrixAdapter(BaseChannelAdapter):
|
|
channel_id: ClassVar[str] = "matrix"
|
|
channel_type: ClassVar[ChannelType] = ChannelType.MATRIX
|
|
webhook_path: ClassVar[str | None] = None
|
|
|
|
text_chunk_limit: ClassVar[int] = 65536
|
|
supports_markdown: ClassVar[bool] = True
|
|
supports_streaming: ClassVar[bool] = True
|
|
streaming_modes: ClassVar[list[str]] = ["off", "block", "lane", "reasoning"]
|
|
max_media_size_mb: ClassVar[int] = 100
|
|
|
|
capabilities = ChannelCapabilities(
|
|
chat_types=["direct", "group", "thread"],
|
|
reactions=True,
|
|
edit=True,
|
|
unsend=True,
|
|
reply=True,
|
|
threads=True,
|
|
media=True,
|
|
pin=True,
|
|
polls=True,
|
|
sticker=True,
|
|
location=True,
|
|
supports_markdown=True,
|
|
supports_streaming=True,
|
|
streaming_modes=["off", "block", "lane", "reasoning"],
|
|
block_streaming=True,
|
|
lane_streaming=True,
|
|
reasoning_streaming=True,
|
|
text_chunk_limit=65536,
|
|
max_media_size_mb=100,
|
|
)
|
|
meta = ChannelMeta(id="matrix", label="Matrix")
|
|
|
|
def __init__(self, config: dict[str, Any] | None = None):
|
|
super().__init__(config)
|
|
self._status = ChannelStatus.DISCONNECTED
|
|
self._homeserver: str = ""
|
|
self._client: AsyncClient | None = None
|
|
self._monitor: MatrixMonitor | None = None
|
|
self._ready_event = asyncio.Event()
|
|
self._joined_rooms: set[str] = set()
|
|
self._direct_rooms: set[str] = set()
|
|
self._ready_at = utc_now_naive()
|
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
|
|
self._normalizer = MatrixNormalizer(self)
|
|
self._session = MatrixSessionHelper(self)
|
|
self._mention_strategy = GroupMentionStrategy(self.config)
|
|
self._stream_state: dict[str, int] = {}
|
|
crypto_store_dir = self.config.get("crypto_store_dir", "./matrix_crypto_store")
|
|
self._sync_token_store = SyncTokenStore(crypto_store_dir, self.config.get("user_id", "unknown"))
|
|
|
|
@property
|
|
def user_id(self) -> str:
|
|
return self._client.user_id if self._client else ""
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
return self._status.value
|
|
|
|
async def connect(self) -> None:
|
|
self._status = ChannelStatus.CONNECTING
|
|
self._ready_event.clear()
|
|
|
|
homeserver = self.config.get("homeserver", "https://matrix.org")
|
|
access_token = self.config.get("access_token")
|
|
password = self.config.get("password")
|
|
user_id = self.config.get("user_id", "")
|
|
device_id = self.config.get("device_id", "yuxi-bot")
|
|
|
|
self._check_private_network()
|
|
|
|
if not user_id:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelException("Matrix user_id not configured")
|
|
|
|
crypto_store_dir = self.config.get(
|
|
"crypto_store_dir", f"./matrix_crypto_store/{homeserver.replace('://', '_')}"
|
|
)
|
|
|
|
self._client = AsyncClient(
|
|
homeserver=homeserver,
|
|
user=user_id,
|
|
store_path=crypto_store_dir,
|
|
)
|
|
|
|
if access_token:
|
|
self._client.access_token = access_token
|
|
self._client.user_id = user_id
|
|
logger.info("Matrix: using configured access_token")
|
|
elif password:
|
|
try:
|
|
resp = await self._client.login(password=password, device_id=device_id)
|
|
if isinstance(resp, LoginResponse):
|
|
self._client.access_token = resp.access_token
|
|
self._client.user_id = resp.user_id
|
|
logger.info(f"Matrix login ok: {resp.user_id} @ {resp.homeserver} (device: {resp.device_id})")
|
|
else:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelAuthenticationError()
|
|
except LoginError:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelAuthenticationError()
|
|
else:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelAuthenticationError()
|
|
|
|
await import_room_keys(self._client, crypto_store_dir)
|
|
|
|
crypto_ready = check_crypto_store_ready(crypto_store_dir)
|
|
if crypto_ready["status"] == "unavailable":
|
|
logger.warning(format_encryption_unavailable_error(self.config))
|
|
elif crypto_ready["status"] == "empty":
|
|
logger.info(f"Matrix crypto store ready for first use: {crypto_store_dir}")
|
|
|
|
sync_timeout_ms = self.config.get("sync_timeout_ms", 30000)
|
|
initial_sync_token = self._sync_token_store.load()
|
|
if initial_sync_token:
|
|
logger.info("Matrix resuming sync from stored token")
|
|
|
|
try:
|
|
sync_resp = await self._client.sync(
|
|
timeout_ms=sync_timeout_ms,
|
|
since=initial_sync_token,
|
|
)
|
|
if sync_resp:
|
|
self._joined_rooms = set(sync_resp.rooms.join.keys())
|
|
self._direct_rooms = self._resolve_direct_rooms()
|
|
logger.info(f"Matrix initial sync: {len(self._joined_rooms)} rooms")
|
|
else:
|
|
logger.warning("Matrix initial sync returned empty response")
|
|
except (SyncError, LocalProtocolError) as e:
|
|
self._status = ChannelStatus.ERROR
|
|
raise ChannelException(f"Matrix initial sync failed: {e}", retryable=True)
|
|
|
|
room_allowlist = self.config.get("rooms", {})
|
|
for room_id, room_config in room_allowlist.items():
|
|
if not room_config.get("allow", True):
|
|
continue
|
|
if not should_auto_join(room_id, self.config):
|
|
continue
|
|
if room_id not in self._joined_rooms:
|
|
try:
|
|
resp = await self._client.join(room_id)
|
|
if hasattr(resp, "room_id"):
|
|
self._joined_rooms.add(room_id)
|
|
logger.info(f"Matrix joined room: {room_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Matrix join room {room_id} failed: {e}")
|
|
|
|
self._monitor = MatrixMonitor(
|
|
self.config,
|
|
self._client,
|
|
self,
|
|
sync_token_store=self._sync_token_store,
|
|
)
|
|
self._monitor.on_message(self._handle_message)
|
|
await self._monitor.start(initial_since=sync_resp.next_batch if sync_resp else initial_sync_token)
|
|
|
|
self._ready_event.set()
|
|
self._ready_at = utc_now_naive()
|
|
self._status = ChannelStatus.CONNECTED
|
|
self._homeserver = homeserver
|
|
logger.info(f"Matrix adapter connected: {user_id} @ {homeserver}")
|
|
|
|
async def disconnect(self) -> None:
|
|
self._status = ChannelStatus.DISCONNECTED
|
|
self._ready_event.clear()
|
|
|
|
if self._monitor:
|
|
await self._monitor.stop()
|
|
self._monitor = None
|
|
|
|
if self._client:
|
|
try:
|
|
await self._client.close()
|
|
except Exception:
|
|
pass
|
|
self._client = None
|
|
|
|
self._joined_rooms.clear()
|
|
self._direct_rooms.clear()
|
|
self._stream_state.clear()
|
|
logger.info("Matrix adapter disconnected")
|
|
|
|
async def create_poll(
|
|
self, room_id: str, question: str, answers: list[str], max_selections: int = 1
|
|
) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do():
|
|
return await create_poll(self._client, room_id, question, answers, max_selections=max_selections)
|
|
|
|
return await self._send_with_circuit(_do)
|
|
|
|
async def vote_poll(self, room_id: str, poll_event_id: str, answer_ids: list[str]) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do():
|
|
return await send_poll_response(self._client, room_id, poll_event_id, answer_ids)
|
|
|
|
return await self._send_with_circuit(_do)
|
|
|
|
async def end_poll(self, room_id: str, poll_event_id: str, summary: str = "") -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do():
|
|
return await end_poll(self._client, room_id, poll_event_id, summary)
|
|
|
|
return await self._send_with_circuit(_do)
|
|
|
|
async def send_location(
|
|
self, room_id: str, lat: float, lon: float, body: str = "", description: str = ""
|
|
) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do():
|
|
return await send_location(self._client, room_id, lat, lon, body, description)
|
|
|
|
return await self._send_with_circuit(_do)
|
|
|
|
async def send_sticker(
|
|
self, room_id: str, sticker_url: str, body: str = "", mimetype: str = "image/png"
|
|
) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do():
|
|
return await send_sticker(self._client, room_id, sticker_url, body, mimetype)
|
|
|
|
return await self._send_with_circuit(_do)
|
|
|
|
async def send_ack_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult | None:
|
|
ack_reaction = self.config.get("ackReaction")
|
|
if not ack_reaction or not self._client:
|
|
return None
|
|
return await self.send_reaction(chat_id, msg_id, ack_reaction)
|
|
|
|
def _should_send_ack(self, chat_type: str) -> bool:
|
|
scope = self.config.get("ackReactionScope", "group-mentions")
|
|
if scope == "always":
|
|
return True
|
|
if scope == "dm-only":
|
|
return chat_type == "direct"
|
|
if scope == "group-mentions":
|
|
return chat_type == "group"
|
|
if scope == "never":
|
|
return False
|
|
return False
|
|
|
|
def _check_private_network(self) -> None:
|
|
allow_private = self.config.get("dangerouslyAllowPrivateNetwork", False)
|
|
if allow_private:
|
|
return
|
|
error = SSRFGuard.validate_url(self._homeserver, allow_private)
|
|
if error:
|
|
logger.warning(f"Matrix private network warning: {error}")
|
|
|
|
async def send(self, response: ChannelResponse) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
chat_id = response.identity.channel_chat_id
|
|
root_event_id = response.metadata.get("root_event_id")
|
|
content = response.content
|
|
prefix = self.config.get("responsePrefix", "")
|
|
if prefix and content:
|
|
content = f"{prefix} {content}"
|
|
|
|
chunk_limit = min(self.text_chunk_limit, 65536)
|
|
|
|
if len(content.encode("utf-8")) <= chunk_limit:
|
|
final_response = ChannelResponse(
|
|
identity=response.identity,
|
|
message_type=response.message_type,
|
|
content=content,
|
|
reply_to_message_id=response.reply_to_message_id,
|
|
metadata=response.metadata,
|
|
timestamp=response.timestamp,
|
|
)
|
|
|
|
async def _do_send():
|
|
if root_event_id:
|
|
return await send_thread_message(self._client, chat_id, root_event_id, content)
|
|
return await send_formatted_text(self._client, chat_id, final_response)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
return await self._send_chunked(chat_id, root_event_id, response, chunk_limit, content)
|
|
|
|
async def send_media(
|
|
self,
|
|
chat_id: str,
|
|
media_type: str,
|
|
data: Any,
|
|
filename: str = "",
|
|
caption: str = "",
|
|
reply_to: str | None = None,
|
|
) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
media_data = data if isinstance(data, bytes) else str(data).encode("utf-8")
|
|
max_mb = self.config.get("mediaMaxMb", self.max_media_size_mb)
|
|
max_bytes = max_mb * 1024 * 1024
|
|
if len(media_data) > max_bytes:
|
|
return DeliveryResult(
|
|
success=False,
|
|
error=f"Media size {len(media_data)} bytes exceeds limit of {max_bytes} bytes ({max_mb} MB)",
|
|
)
|
|
|
|
async def _do_send():
|
|
return await send_formatted_media(
|
|
self._client,
|
|
chat_id,
|
|
media_type,
|
|
media_data,
|
|
"application/octet-stream",
|
|
filename=filename,
|
|
caption=caption,
|
|
reply_to=reply_to,
|
|
)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_send():
|
|
return await send_stream_chunk(
|
|
self._client,
|
|
chat_id,
|
|
msg_id,
|
|
content,
|
|
finished=True,
|
|
)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_send():
|
|
return await send_stream_chunk(
|
|
self._client,
|
|
chat_id,
|
|
msg_id,
|
|
chunk,
|
|
finished,
|
|
stream_state=self._stream_state,
|
|
)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_send():
|
|
return await send_reaction(self._client, chat_id, msg_id, emoji)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_send():
|
|
resp = await self._client.room_redact(
|
|
room_id=chat_id,
|
|
event_id=msg_id,
|
|
)
|
|
return DeliveryResult(success=True, message_id=resp.event_id)
|
|
|
|
return await self._send_with_circuit(_do_send)
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
if not self._client:
|
|
raise ChannelException("Client not initialized")
|
|
|
|
try:
|
|
resp, _ = await self._client.download(file_id)
|
|
if isinstance(resp, DownloadResponse):
|
|
return resp.body
|
|
raise ChannelException(f"Media download failed for: {file_id}")
|
|
except ChannelException:
|
|
raise
|
|
except Exception as e:
|
|
raise ChannelException(f"Media download failed for {file_id}: {e}")
|
|
|
|
async def send_typing_indicator(self, chat_id: str, typing: bool = True, timeout_ms: int = 30000) -> None:
|
|
if not self._client:
|
|
return
|
|
try:
|
|
await self._client.room_typing(
|
|
room_id=chat_id,
|
|
typing_state=typing,
|
|
timeout=timeout_ms,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Matrix typing indicator failed: {e}")
|
|
|
|
async def send_read_receipt(self, chat_id: str, event_id: str) -> None:
|
|
if not self._client:
|
|
return
|
|
try:
|
|
await self._client.room_read_markers(
|
|
room_id=chat_id,
|
|
fully_read_event=event_id,
|
|
read_event=event_id,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Matrix read receipt failed: {e}")
|
|
|
|
async def invite_user(self, room_id: str, user_id: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_invite():
|
|
resp = await self._client.room_invite(room_id, user_id)
|
|
return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None))
|
|
|
|
return await self._send_with_circuit(_do_invite)
|
|
|
|
async def kick_user(self, room_id: str, user_id: str, reason: str = "") -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_kick():
|
|
resp = await self._client.room_kick(room_id, user_id, reason=reason)
|
|
return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None))
|
|
|
|
return await self._send_with_circuit(_do_kick)
|
|
|
|
async def ban_user(self, room_id: str, user_id: str, reason: str = "") -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_ban():
|
|
resp = await self._client.room_ban(room_id, user_id, reason=reason)
|
|
return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None))
|
|
|
|
return await self._send_with_circuit(_do_ban)
|
|
|
|
async def leave_room(self, room_id: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_leave():
|
|
resp = await self._client.room_leave(room_id)
|
|
self._joined_rooms.discard(room_id)
|
|
return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None))
|
|
|
|
return await self._send_with_circuit(_do_leave)
|
|
|
|
async def join_room(self, room_id: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
async def _do_join():
|
|
resp = await self._client.join(room_id)
|
|
self._joined_rooms.add(room_id)
|
|
return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None))
|
|
|
|
return await self._send_with_circuit(_do_join)
|
|
|
|
async def read_messages(self, chat_id: str, limit: int = 50, direction: str = "backward") -> list[dict[str, Any]]:
|
|
if not self._client:
|
|
return []
|
|
|
|
try:
|
|
resp = await self._client.room_messages(
|
|
room_id=chat_id,
|
|
start="",
|
|
limit=limit,
|
|
direction=direction,
|
|
)
|
|
events = getattr(resp, "chunk", [])
|
|
return [
|
|
{
|
|
"event_id": getattr(e, "event_id", ""),
|
|
"sender": getattr(e, "sender", ""),
|
|
"timestamp": getattr(e, "origin_server_ts", 0),
|
|
"content": getattr(e, "source", {}).get("content", {}),
|
|
}
|
|
for e in events
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Matrix read_messages failed for {chat_id}: {e}")
|
|
return []
|
|
|
|
async def get_channel_info(self, chat_id: str) -> dict[str, Any]:
|
|
if not self._client:
|
|
return {}
|
|
|
|
try:
|
|
name_resp = await self._client.room_get_state_event(chat_id, "m.room.name")
|
|
topic_resp = await self._client.room_get_state_event(chat_id, "m.room.topic")
|
|
join_rules = await self._client.room_get_state_event(chat_id, "m.room.join_rules")
|
|
members_resp = await self._client.joined_members(chat_id)
|
|
|
|
name = getattr(name_resp, "name", "") if name_resp else ""
|
|
topic = getattr(topic_resp, "topic", "") if topic_resp else ""
|
|
join_rule = getattr(join_rules, "join_rule", "") if join_rules else ""
|
|
member_count = len(getattr(members_resp, "members", []))
|
|
|
|
return {
|
|
"room_id": chat_id,
|
|
"name": name,
|
|
"topic": topic,
|
|
"join_rule": join_rule,
|
|
"member_count": member_count,
|
|
"is_dm": self._is_dm_room(chat_id),
|
|
"is_joined": chat_id in self._joined_rooms,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Matrix get_channel_info failed for {chat_id}: {e}")
|
|
return {"room_id": chat_id, "error": str(e)}
|
|
|
|
async def get_member_info(self, room_id: str, user_id: str) -> dict[str, Any]:
|
|
if not self._client:
|
|
return {}
|
|
|
|
try:
|
|
dn_resp = await self._client.get_displayname(user_id)
|
|
avatar_resp = await self._client.get_avatar(user_id)
|
|
|
|
display_name = getattr(dn_resp, "displayname", "") if dn_resp else ""
|
|
avatar_url = getattr(avatar_resp, "avatar_url", "") if avatar_resp else ""
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"display_name": display_name,
|
|
"avatar_url": avatar_url,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Matrix get_member_info failed for {user_id}: {e}")
|
|
return {"user_id": user_id, "error": str(e)}
|
|
|
|
async def set_profile(self, display_name: str = "", avatar_url: str = "") -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
try:
|
|
if display_name:
|
|
await self._client.set_displayname(display_name)
|
|
logger.info(f"Matrix display name updated: {display_name}")
|
|
|
|
if avatar_url:
|
|
resolved_url = resolve_avatar_url(avatar_url, self._homeserver) or avatar_url
|
|
await self._client.set_avatar(resolved_url)
|
|
logger.info("Matrix avatar updated")
|
|
|
|
return DeliveryResult(success=True)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def pin_message(self, room_id: str, event_id: str) -> DeliveryResult:
|
|
return await self._manage_pins(room_id, event_id, action="pin")
|
|
|
|
async def unpin_message(self, room_id: str, event_id: str) -> DeliveryResult:
|
|
return await self._manage_pins(room_id, event_id, action="unpin")
|
|
|
|
async def list_pins(self, room_id: str) -> list[str]:
|
|
if not self._client:
|
|
return []
|
|
|
|
try:
|
|
resp = await self._client.room_get_state_event(room_id, "m.room.pinned_events")
|
|
if resp and hasattr(resp, "pinned"):
|
|
return resp.pinned
|
|
return []
|
|
except Exception as e:
|
|
logger.warning(f"Matrix list_pins failed for {room_id}: {e}")
|
|
return []
|
|
|
|
async def _manage_pins(self, room_id: str, event_id: str, action: str) -> DeliveryResult:
|
|
if not self._client:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
try:
|
|
current_pins = await self.list_pins(room_id)
|
|
|
|
if action == "pin" and event_id not in current_pins:
|
|
current_pins.append(event_id)
|
|
elif action == "unpin":
|
|
current_pins = [p for p in current_pins if p != event_id]
|
|
else:
|
|
return DeliveryResult(success=False, error=f"Unknown pin action: {action}")
|
|
|
|
await self._client.room_put_state(
|
|
room_id=room_id,
|
|
event_type="m.room.pinned_events",
|
|
content={"pinned": current_pins},
|
|
)
|
|
return DeliveryResult(success=True)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
def normalize_inbound(self, raw: Any) -> ChannelMessage | None:
|
|
room_id = getattr(raw, "room_id", "unknown")
|
|
return self._normalizer.normalize(raw, room_id)
|
|
|
|
def format_outbound(self, response: ChannelResponse) -> Any:
|
|
return {
|
|
"room_id": response.identity.channel_chat_id,
|
|
"content": response.content,
|
|
"reply_to_message_id": response.reply_to_message_id,
|
|
"metadata": response.metadata,
|
|
}
|
|
|
|
async def health_check(self) -> HealthStatus:
|
|
if self._status != ChannelStatus.CONNECTED or not self._client:
|
|
return HealthStatus(
|
|
status="unhealthy",
|
|
last_error="Matrix not connected",
|
|
)
|
|
|
|
probe_timeout = self.config.get("probe_timeout_s", None)
|
|
result = await probe_matrix(self._client, timeout_s=probe_timeout)
|
|
if result.status == "healthy":
|
|
result.last_connected_at = self._ready_at
|
|
result.metadata["joined_rooms"] = len(self._joined_rooms)
|
|
result.metadata["sync_running"] = self._monitor.running if self._monitor else False
|
|
result.metadata["direct_rooms"] = len(self._direct_rooms)
|
|
return result
|
|
|
|
async def pre_connect(self) -> dict:
|
|
homeserver = self.config.get("homeserver", "https://matrix.org")
|
|
user_id = self.config.get("user_id", "")
|
|
access_token = self.config.get("access_token")
|
|
password = self.config.get("password")
|
|
device_id = self.config.get("device_id", "yuxi-bot")
|
|
probe_timeout = self.config.get("probe_timeout_s", None)
|
|
|
|
if not user_id:
|
|
return {"status": "error", "message": "Missing user_id"}
|
|
|
|
if not access_token and not password:
|
|
return {"status": "error", "message": "Missing access_token or password"}
|
|
|
|
try:
|
|
client = AsyncClient(homeserver=homeserver, user=user_id)
|
|
if access_token:
|
|
client.access_token = access_token
|
|
client.user_id = user_id
|
|
else:
|
|
resp = await client.login(password=password, device_id=device_id)
|
|
if isinstance(resp, LoginResponse):
|
|
client.access_token = resp.access_token
|
|
client.user_id = resp.user_id
|
|
else:
|
|
await client.close()
|
|
return {"status": "error", "message": "Login failed"}
|
|
|
|
prob_result = await probe_matrix(client, timeout_s=probe_timeout)
|
|
await client.close()
|
|
|
|
return {
|
|
"status": "ok",
|
|
"homeserver": homeserver,
|
|
"user_id": user_id,
|
|
"versions_probe": prob_result.status,
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
|
if not self._client:
|
|
return {}
|
|
try:
|
|
dn_resp = await self._client.get_displayname(channel_user_id)
|
|
avatar_resp = await self._client.get_avatar(channel_user_id)
|
|
|
|
raw_display_name = dn_resp.displayname if hasattr(dn_resp, "displayname") else ""
|
|
raw_avatar_url = avatar_resp.avatar_url if hasattr(avatar_resp, "avatar_url") else ""
|
|
|
|
resolved_avatar = resolve_avatar_url(raw_avatar_url, self._homeserver) if raw_avatar_url else ""
|
|
|
|
return {
|
|
"user_id": channel_user_id,
|
|
"display_name": raw_display_name,
|
|
"avatar_url": resolved_avatar or raw_avatar_url,
|
|
}
|
|
except Exception:
|
|
return {}
|
|
|
|
async def update_config(self, config: dict[str, Any]) -> None:
|
|
patch = MatrixAccountPatch.from_dict(config)
|
|
self.config = apply_patch(self.config, patch)
|
|
self._mention_strategy = GroupMentionStrategy(self.config)
|
|
logger.info("Matrix adapter config updated")
|
|
|
|
async def _refresh_token_if_needed(self) -> bool:
|
|
password = self.config.get("password")
|
|
if not password or not self._client:
|
|
return False
|
|
|
|
try:
|
|
resp = await self._client.login(
|
|
password=password,
|
|
device_id=self.config.get("device_id", "yuxi-bot"),
|
|
)
|
|
if isinstance(resp, LoginResponse):
|
|
self._client.access_token = resp.access_token
|
|
self._client.user_id = resp.user_id
|
|
logger.info("Matrix token refreshed")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Matrix token refresh failed: {e}")
|
|
return False
|
|
|
|
async def _send_with_circuit(self, func) -> DeliveryResult:
|
|
try:
|
|
return await self._circuit_breaker.call(func)
|
|
except CircuitBreakerOpenError:
|
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
|
except Exception as e:
|
|
error_type, retry_after = classify_send_error(e)
|
|
|
|
if error_type == "auth":
|
|
if await self._refresh_token_if_needed():
|
|
try:
|
|
return await self._circuit_breaker.call(func)
|
|
except Exception as retry_e:
|
|
return DeliveryResult(success=False, error=str(retry_e))
|
|
return DeliveryResult(success=False, error="Token refresh failed")
|
|
|
|
if error_type == "rate_limit" and retry_after:
|
|
await asyncio.sleep(retry_after / 1000)
|
|
try:
|
|
return await self._circuit_breaker.call(func)
|
|
except Exception as retry_e:
|
|
return DeliveryResult(success=False, error=str(retry_e))
|
|
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def _send_chunked(
|
|
self, chat_id: str, root_event_id: str | None, response: ChannelResponse, limit: int, content: str
|
|
) -> DeliveryResult:
|
|
chunk_mode = self.config.get("chunkMode", "length")
|
|
chunks = _split_content(content, limit, chunk_mode)
|
|
|
|
for chunk_text in chunks:
|
|
chunk_response = ChannelResponse(
|
|
identity=response.identity,
|
|
message_type=response.message_type,
|
|
content=chunk_text,
|
|
reply_to_message_id=response.reply_to_message_id,
|
|
metadata=response.metadata,
|
|
timestamp=response.timestamp,
|
|
)
|
|
|
|
async def _do_chunk():
|
|
if root_event_id:
|
|
return await send_thread_message(self._client, chat_id, root_event_id, chunk_text)
|
|
return await send_formatted_text(self._client, chat_id, chunk_response)
|
|
|
|
result = await self._send_with_circuit(_do_chunk)
|
|
if not result.success:
|
|
return result
|
|
|
|
logger.debug(f"Matrix chunk sent: {len(chunk_text)} chars")
|
|
|
|
return DeliveryResult(success=True)
|
|
|
|
def _is_dm_room(self, room_id: str) -> bool:
|
|
return room_id in self._direct_rooms
|
|
|
|
def _resolve_direct_rooms(self) -> set[str]:
|
|
try:
|
|
account_data = self._client.account_data if self._client else {}
|
|
if account_data:
|
|
direct_data = account_data.get("m.direct", {})
|
|
if isinstance(direct_data, dict):
|
|
result = set()
|
|
for room_ids in direct_data.values():
|
|
if isinstance(room_ids, list):
|
|
result.update(room_ids)
|
|
return result
|
|
except Exception:
|
|
pass
|
|
return set()
|
|
|
|
|
|
def _split_content(content: str, limit: int, mode: str) -> list[str]:
|
|
encoded = content.encode("utf-8")
|
|
if len(encoded) <= limit:
|
|
return [content]
|
|
|
|
if mode == "newline":
|
|
return _split_by_newlines(encoded, limit)
|
|
|
|
return _split_by_length(encoded, limit)
|
|
|
|
|
|
def _split_by_length(encoded: bytes, limit: int) -> list[str]:
|
|
chunks = []
|
|
offset = 0
|
|
while offset < len(encoded):
|
|
chunk_bytes = encoded[offset : offset + limit]
|
|
chunks.append(chunk_bytes.decode("utf-8", errors="ignore"))
|
|
offset += limit
|
|
return chunks
|
|
|
|
|
|
def _split_by_newlines(encoded: bytes, limit: int) -> list[str]:
|
|
text = encoded.decode("utf-8")
|
|
lines = text.split("\n")
|
|
chunks = []
|
|
current = ""
|
|
|
|
for line in lines:
|
|
candidate = f"{current}\n{line}" if current else line
|
|
if len(candidate.encode("utf-8")) > limit and current:
|
|
chunks.append(current)
|
|
current = line
|
|
else:
|
|
current = candidate
|
|
|
|
if current:
|
|
chunks.append(current)
|
|
|
|
return chunks or [text]
|