新增 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: 类型定义
246 lines
8.3 KiB
Python
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}"
|