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

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]