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

275 lines
7.4 KiB
Python

from __future__ import annotations
from typing import TYPE_CHECKING
from yuxi.channels.models import ChannelResponse, DeliveryResult
from yuxi.utils.logging_config import logger
from .formatter import markdown_to_matrix_html
from .location import build_outbound_location
from .sticker import build_outbound_sticker
if TYPE_CHECKING:
from nio import AsyncClient
async def send_formatted_text(
client: AsyncClient,
room_id: str,
response: ChannelResponse,
reply_to_mode: str = "all",
) -> DeliveryResult:
content: dict = {
"msgtype": "m.text",
"body": response.content,
}
if response.content:
html_body = markdown_to_matrix_html(response.content)
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html_body
if response.reply_to_message_id:
if reply_to_mode != "off":
content["m.relates_to"] = {
"m.in_reply_to": {"event_id": response.reply_to_message_id},
}
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_formatted_media(
client: AsyncClient,
room_id: str,
media_type: str,
media_data: bytes,
mime_type: str,
filename: str = "",
caption: str = "",
reply_to: str | None = None,
) -> DeliveryResult:
try:
upload_resp, _ = await client.upload(
media_data,
content_type=mime_type,
filename=filename or "file",
)
except Exception as e:
return DeliveryResult(success=False, error=f"Media upload failed: {e}")
mxc_url = upload_resp.content_uri
logger.debug(f"Matrix media uploaded: {mxc_url}")
msgtype_map = {
"image": "m.image",
"video": "m.video",
"audio": "m.audio",
"file": "m.file",
}
msgtype = msgtype_map.get(media_type, "m.file")
content: dict = {
"msgtype": msgtype,
"body": filename or caption or "media",
"url": mxc_url,
"info": {"mimetype": mime_type},
}
if caption:
content["body"] = caption
if reply_to:
content["m.relates_to"] = {
"m.in_reply_to": {"event_id": reply_to},
}
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(
client: AsyncClient,
room_id: str,
target_event_id: str,
reaction: str,
) -> DeliveryResult:
content = {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": target_event_id,
"key": reaction,
},
}
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.reaction",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_thread_message(
client: AsyncClient,
room_id: str,
root_event_id: str,
text: str,
) -> DeliveryResult:
html_body = markdown_to_matrix_html(text)
msg_content = {
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": html_body,
"m.relates_to": {
"rel_type": "m.thread",
"event_id": root_event_id,
"is_falling_back": False,
},
}
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content=msg_content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_location(
client: AsyncClient,
room_id: str,
lat: float,
lon: float,
body: str = "",
description: str = "",
) -> DeliveryResult:
content = build_outbound_location(body, lat, lon, description)
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_sticker(
client: AsyncClient,
room_id: str,
sticker_url: str,
body: str = "",
mimetype: str = "image/png",
) -> DeliveryResult:
content = build_outbound_sticker(sticker_url, body, mimetype)
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.sticker",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
_STREAM_SENT_LENGTHS: dict[str, int] = {}
_AUTH_ERROR_CODES = frozenset({"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_MISSING_TOKEN"})
_RATE_LIMIT_STATUS = 429
def classify_send_error(exc: Exception) -> tuple[str, int | None]:
"""返回 (error_type, retry_after_ms_or_none)
error_type: 'auth' | 'rate_limit' | 'other'
"""
status_code = getattr(exc, "status_code", None)
errcode = getattr(exc, "errcode", None) or getattr(exc, "message", "")
if errcode in _AUTH_ERROR_CODES:
return "auth", None
if status_code == _RATE_LIMIT_STATUS:
retry_after = getattr(exc, "retry_after_ms", None)
if retry_after is None:
retry_after = 5000
return "rate_limit", retry_after
return "other", None
async def send_stream_chunk(
client: AsyncClient,
room_id: str,
message_id: str,
chunk_text: str,
finished: bool = False,
stream_state: dict[str, int] | None = None,
) -> DeliveryResult:
html_body = markdown_to_matrix_html(chunk_text)
stream_key = f"{room_id}:{message_id}"
lengths = stream_state if stream_state is not None else _STREAM_SENT_LENGTHS
prev_length = lengths.get(stream_key, 0)
if prev_length > 0 and prev_length < len(chunk_text):
delta_text = chunk_text[prev_length:]
delta_html = markdown_to_matrix_html(delta_text)
else:
delta_text = chunk_text
delta_html = html_body
content = {
"msgtype": "m.text",
"body": delta_text,
"format": "org.matrix.custom.html",
"formatted_body": delta_html,
"m.new_content": {
"msgtype": "m.text",
"body": chunk_text,
"format": "org.matrix.custom.html",
"formatted_body": html_body,
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": message_id,
},
}
if finished:
lengths.pop(stream_key, None)
else:
lengths[stream_key] = len(chunk_text)
try:
resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
)
return DeliveryResult(success=True, message_id=resp.event_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))