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}"