ForcePilot/backend/package/yuxi/channels/adapters/synologychat/normalize.py

237 lines
6.9 KiB
Python
Raw Normal View History

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