"""Event normalization for Synology Chat polling events. Converts raw DSM polling data into unified ChannelMessage objects. Handles multiple message types, mentions extraction, reply-to detection, and event type classification. """ from __future__ import annotations import re from datetime import datetime from typing import Any from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelType, ChatType, EventType, MentionsInfo, MessageType, ) from yuxi.utils.datetime_utils import utc_now_naive _MENTION_RE = re.compile(r"@(\w[\w.-]{0,31})") _URL_RE = re.compile(r"https?://[^\s]+") _FILE_IMAGE_MIMES = frozenset( { "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/svg+xml", "image/tiff", } ) _FILE_VIDEO_MIMES = frozenset( { "video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-msvideo", "video/x-matroska", } ) _FILE_AUDIO_MIMES = frozenset( { "audio/mpeg", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac", "audio/x-m4a", } ) def classify_file_type(file_info: dict[str, Any]) -> tuple[str, MessageType]: """Return (attachment_type, message_type) from file metadata.""" mime = (file_info.get("mime_type") or "").lower() is_image = file_info.get("is_image", False) if is_image or mime in _FILE_IMAGE_MIMES: return "image", MessageType.IMAGE if mime in _FILE_VIDEO_MIMES: return "video", MessageType.VIDEO if mime in _FILE_AUDIO_MIMES: return "audio", MessageType.AUDIO return "file", MessageType.FILE def extract_mentions(text: str, bot_name: str | None = None) -> MentionsInfo: names = _MENTION_RE.findall(text) if not names: return MentionsInfo() is_bot_mentioned = bot_name is not None and bot_name in names return MentionsInfo( mentioned_user_ids=names, is_bot_mentioned=is_bot_mentioned, raw_text=text, ) def extract_urls(text: str) -> list[str]: return _URL_RE.findall(text) _INJECTION_PATTERNS = [ re.compile(r"ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|prompts?|messages?)", re.IGNORECASE), re.compile(r"forget\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|prompts?|messages?)", re.IGNORECASE), re.compile( r"(?:you\s+are|act\s+as|pretend\s+(?:to\s+be|you\s+are))\s+(?:now\s+)?(?:DAN|jailbreak|evil|unfiltered)", re.IGNORECASE, ), re.compile(r"(?:system\s*:\s*|\[system\]|system\s+prompt\s*:)", re.IGNORECASE), ] _INBOUND_MAX_CHARS = 4000 def sanitize_input(text: str) -> str: if not text: return text for pattern in _INJECTION_PATTERNS: text = pattern.sub("[filtered]", text) return text[:_INBOUND_MAX_CHARS] def parse_timestamp(ts_val: Any) -> datetime | None: if not ts_val: return None try: ts = float(ts_val) return datetime.fromtimestamp(ts) except (TypeError, ValueError, OSError): return None def normalize_event( event: dict[str, Any], channel_id: str, channel_type: ChannelType, bot_name: str | None = None, trigger_word: str | None = None, ) -> ChannelMessage: user_id = str(event.get("user_id", "")) chat_channel_id = str(event.get("channel_id", "")) message_id = str(event.get("message_id", "")) event_type_str = event.get("event_type", "") chat_type_raw = event.get("channel_type", "user") chat_type = ChatType.DIRECT if chat_type_raw == "user" else ChatType.GROUP content = event.get("message", {}).get("text", "") or "" file_info = event.get("message", {}).get("file") content = sanitize_input(content) if trigger_word and content.startswith(trigger_word): content = content[len(trigger_word) :].lstrip() attachments: list[Attachment] = [] message_type = MessageType.TEXT if file_info: attach_type, msg_type = classify_file_type(file_info) attachments.append( Attachment( type=attach_type, url=file_info.get("url"), filename=file_info.get("filename"), size_bytes=file_info.get("file_size", 0), mime_type=file_info.get("mime_type"), ) ) message_type = msg_type if not content: content = f"({attach_type})" if content.startswith("/"): message_type = MessageType.COMMAND reply_to_id: str | None = None quote_info = event.get("quote") or event.get("message", {}).get("quote") if quote_info: reply_to_id = str(quote_info.get("message_id", "") or quote_info.get("id", "")) is_forwarded = False forwarded_from = None forward_info = event.get("forward") or event.get("message", {}).get("forward") if forward_info: is_forwarded = True forwarded_from = forward_info.get("user_id") or forward_info.get("username") mentions = extract_mentions(content, bot_name) extracted_urls = extract_urls(content) event_type = _resolve_event_type(event_type_str) metadata = { "chat_type": chat_type.value, "command_authorized": True, "dsm_chat_type": chat_type.value, "dsm_channel_name": event.get("channel_name", ""), "dsm_user_name": event.get("user_name", ""), } if reply_to_id: metadata["reply_to_message_id"] = reply_to_id if is_forwarded: metadata["is_forwarded"] = True metadata["forwarded_from"] = forwarded_from timestamp = parse_timestamp(event.get("timestamp")) return ChannelMessage( identity=ChannelIdentity( channel_id=channel_id, channel_type=channel_type, channel_user_id=user_id, channel_chat_id=chat_channel_id, channel_message_id=message_id, ), event_type=event_type, message_type=message_type, chat_type=chat_type, content=content, attachments=attachments, mentions=mentions if mentions.mentioned_user_ids else None, extracted_urls=extracted_urls, reply_to_message_id=reply_to_id, metadata=metadata, timestamp=timestamp or utc_now_naive(), ) def _resolve_event_type(event_type_str: str) -> EventType: if not event_type_str: return EventType.MESSAGE_RECEIVED type_map = { "message": EventType.MESSAGE_RECEIVED, "message_received": EventType.MESSAGE_RECEIVED, "message_updated": EventType.MESSAGE_UPDATED, "message_edited": EventType.MESSAGE_UPDATED, "message_deleted": EventType.MESSAGE_DELETED, "bot_added": EventType.BOT_ADDED, "bot_removed": EventType.BOT_REMOVED, "member_joined": EventType.MEMBER_JOINED, "member_left": EventType.MEMBER_LEFT, } return type_map.get(event_type_str, EventType.MESSAGE_RECEIVED)