ForcePilot/backend/package/yuxi/channel/extensions/matrix/monitor.py
Kris 4e9c6dd8ab feat(channel): 添加 Matrix 渠道扩展
新增 Matrix 渠道扩展,支持在 Yuxi 平台中集成 Matrix 去中心化通讯协议。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 端到端加密
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- room_resolver: 房间解析
- dm_tracker: 私聊追踪
- rate_limiter: 速率限制
- actions: 动作处理
- constants: 常量定义
- utils: 工具函数
- types: 类型定义
2026-05-21 11:18:13 +08:00

246 lines
8.3 KiB
Python

from __future__ import annotations
import logging
from datetime import UTC, datetime
from yuxi.channel.sdk import GroupContext, MessageType, PeerInfo, PeerKind, UnifiedMessage
from .constants import (
EVENT_TYPE_ENCRYPTED,
EVENT_TYPE_MESSAGE,
EVENT_TYPE_POLL_END,
EVENT_TYPE_POLL_RESPONSE,
EVENT_TYPE_POLL_START,
EVENT_TYPE_REACTION,
EVENT_TYPE_REDACTION,
M_FORMAT_HTML,
MSG_TYPE_AUDIO,
MSG_TYPE_FILE,
MSG_TYPE_IMAGE,
MSG_TYPE_LOCATION,
MSG_TYPE_STICKER,
MSG_TYPE_TEXT,
MSG_TYPE_VIDEO,
MSG_TYPE_VOICE,
REL_TYPE_REPLACE,
)
from .crypto import diagnose_decryption
from .utils import strip_html_tags
logger = logging.getLogger(__name__)
def normalize_matrix_event(
event,
own_user_id: str,
account_id: str,
dm_tracker=None,
) -> UnifiedMessage | None:
sender = _extract_sender(event)
if sender == own_user_id:
return None
room_id = getattr(event, "room_id", "") or ""
event_id = getattr(event, "event_id", "") or ""
source = getattr(event, "source", {}) or {}
event_type = _extract_event_type(event)
content = _extract_content(event)
if event_type == EVENT_TYPE_ENCRYPTED and "body" not in content:
diagnosis = diagnose_decryption(event)
logger.warning(
"Failed to decrypt event %s in room %s: algorithm=%s sender_key=%s",
event_id,
room_id,
diagnosis.get("algorithm", "unknown"),
diagnosis.get("sender_key", "unknown"),
)
return UnifiedMessage(
msg_id=event_id,
channel_type="matrix",
account_id=account_id,
content="[Encrypted message — unable to decrypt]",
sender=PeerInfo(kind=PeerKind.DIRECT if is_dm else PeerKind.GROUP, id=sender),
message_type=MessageType.EVENT,
group=GroupContext(id=room_id) if not is_dm else None,
timestamp=datetime.now(UTC),
metadata={
"room_id": room_id,
"event_type": EVENT_TYPE_ENCRYPTED,
"decryption_failed": True,
},
)
is_dm = dm_tracker.is_direct_room(room_id) if dm_tracker else False
peer_kind = PeerKind.DIRECT if is_dm else PeerKind.GROUP
msg_type, text_content, media_urls, edit_target_id = _parse_message_content(event_type, content)
msg = UnifiedMessage(
msg_id=event_id,
channel_type="matrix",
account_id=account_id,
content=text_content or "",
sender=PeerInfo(
kind=peer_kind,
id=sender,
display_name=content.get("sender_display_name", sender),
),
message_type=msg_type,
media_urls=media_urls,
group=GroupContext(
id=room_id,
name=room_id,
)
if not is_dm
else None,
timestamp=datetime.fromtimestamp(source.get("origin_server_ts", 0) / 1000, tz=UTC),
raw_payload=source,
metadata={
"room_id": room_id,
"event_type": event_type,
"is_dm": is_dm,
},
)
if edit_target_id:
msg.metadata["edit_target_id"] = edit_target_id
if reply_to_id := _extract_reply_to(content):
msg.reply_to_id = reply_to_id
thread_root = _extract_thread_root(content)
if thread_root:
msg.thread_parent_id = thread_root
msg.message_thread_id = thread_root
if is_dm:
msg.conversation_label = f"DM: {sender}"
return msg
def _extract_sender(event) -> str:
sender = getattr(event, "sender", "")
if hasattr(sender, "user_id"):
return sender.user_id
return str(sender) if sender else ""
def _extract_event_type(event) -> str:
return getattr(event, "type", "") or getattr(event, "source", {}).get("type", "")
def _extract_content(event) -> dict:
content = getattr(event, "source", {}).get("content", {})
if not content and hasattr(event, "content"):
content = event.content or {}
return content if isinstance(content, dict) else {}
def _parse_message_content(event_type: str, content: dict) -> tuple[MessageType, str, list[str], str | None]:
edit_target_id = _detect_edit(content)
if event_type == EVENT_TYPE_REACTION:
relates_to = content.get("m.relates_to", {})
key = relates_to.get("key", "")
return MessageType.EVENT, f"reacted with {key}", [], None
if event_type == EVENT_TYPE_REDACTION:
redacts = content.get("redacts", "")
reason = content.get("reason", "")
detail = f"redacted {redacts}" + (f" (reason: {reason})" if reason else "")
return MessageType.EVENT, detail, [], None
if event_type in (EVENT_TYPE_POLL_START, EVENT_TYPE_POLL_RESPONSE, EVENT_TYPE_POLL_END):
poll_info = _format_poll_event(event_type, content)
return MessageType.EVENT, poll_info, [], None
if event_type != EVENT_TYPE_MESSAGE and event_type != EVENT_TYPE_ENCRYPTED:
return MessageType.EVENT, str(content.get("body", "")), [], edit_target_id
msgtype = content.get("msgtype", MSG_TYPE_TEXT)
if msgtype == MSG_TYPE_TEXT or msgtype == "m.notice" or msgtype == "m.emote":
text = content.get("body", "")
if content.get("format") == M_FORMAT_HTML:
text = strip_html_tags(content.get("formatted_body", text))
return MessageType.TEXT, text, [], edit_target_id
if msgtype == MSG_TYPE_IMAGE:
url = content.get("url", "")
text = content.get("body", "")
return MessageType.IMAGE, text, [url] if url else [], edit_target_id
if msgtype == MSG_TYPE_FILE:
url = content.get("url", "")
text = content.get("body", "")
return MessageType.FILE, text, [url] if url else [], edit_target_id
if msgtype == MSG_TYPE_AUDIO:
url = content.get("url", "")
text = content.get("body", "")
return MessageType.VOICE, text, [url] if url else [], edit_target_id
if msgtype == MSG_TYPE_VIDEO:
url = content.get("url", "")
text = content.get("body", "")
return MessageType.VIDEO, text, [url] if url else [], edit_target_id
if msgtype == MSG_TYPE_LOCATION:
geo_uri = content.get("geo_uri", "")
text = content.get("body", f"Location: {geo_uri}")
return MessageType.TEXT, text, [], edit_target_id
if msgtype == MSG_TYPE_STICKER:
url = content.get("url", "")
text = content.get("body", "Sticker")
return MessageType.IMAGE, text, [url] if url else [], edit_target_id
if msgtype == MSG_TYPE_VOICE:
url = content.get("url", "")
text = content.get("body", "Voice message")
return MessageType.VOICE, text, [url] if url else [], edit_target_id
return MessageType.TEXT, content.get("body", ""), [], edit_target_id
def _extract_reply_to(content: dict) -> str | None:
relates_to = content.get("m.relates_to", {})
if isinstance(relates_to, dict):
in_reply_to = relates_to.get("m.in_reply_to", {})
if isinstance(in_reply_to, dict):
return in_reply_to.get("event_id")
return None
def _extract_thread_root(content: dict) -> str | None:
relates_to = content.get("m.relates_to", {})
if isinstance(relates_to, dict) and relates_to.get("rel_type") == "m.thread":
return relates_to.get("event_id")
return None
def _detect_edit(content: dict) -> str | None:
if "m.new_content" in content:
relates_to = content.get("m.relates_to", {})
if isinstance(relates_to, dict) and relates_to.get("rel_type") == REL_TYPE_REPLACE:
return relates_to.get("event_id")
return None
def _format_poll_event(event_type: str, content: dict) -> str:
if event_type == EVENT_TYPE_POLL_START:
question = content.get("question", {}).get("body", {}) if isinstance(content.get("question"), dict) else ""
question_text = question.get("body", "") if isinstance(question, dict) else str(question or "")
answers = content.get("answers", [])
options = [a.get("org.matrix.msc3381.poll.response", {}).get("id", "") for a in answers]
return f"poll started: {question_text} [{len(options)} options]"
if event_type == EVENT_TYPE_POLL_RESPONSE:
answers = content.get("org.matrix.msc3381.poll.response", {}).get("answers", [])
return f"poll vote: {answers}"
if event_type == EVENT_TYPE_POLL_END:
return "poll ended"
return f"{event_type}: {content}"