ForcePilot/backend/package/yuxi/channels/adapters/synologychat/normalize.py
Kris 3ad218e537 feat(synologychat): 新增群晖Chat适配器完整实现
新增群晖Chat渠道适配器的全套实现,包括:
1. 基础适配器与导出接口定义
2. DSM API认证、探测与会话管理
3. 轮询与Webhook两种消息接收方式
4. 消息去重、格式化与规范化处理
5. 多账号支持与权限安全策略
6. 目录用户/群组发现功能
7. 审批配对与流量控制机制
8. 安全审计与配置检查功能
2026-05-12 00:49:16 +08:00

237 lines
6.9 KiB
Python

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