新增群晖Chat渠道适配器的全套实现,包括: 1. 基础适配器与导出接口定义 2. DSM API认证、探测与会话管理 3. 轮询与Webhook两种消息接收方式 4. 消息去重、格式化与规范化处理 5. 多账号支持与权限安全策略 6. 目录用户/群组发现功能 7. 审批配对与流量控制机制 8. 安全审计与配置检查功能
237 lines
6.9 KiB
Python
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)
|