- 调整依赖导入顺序与清理冗余空行 - 修复setup wizard步骤存储逻辑 - 新增消息编辑/转发标记、卡片控件支持 - 完善目标校验、媒体上传、配对存储功能 - 优化流式响应与错误处理逻辑 - 增强SSRF防护与配置灵活性 - 新增/扩展内置命令与卡片处理能力 - 调整媒体大小限制与类型参数
252 lines
7.6 KiB
Python
252 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
from .mentions import parse_mentions
|
|
from .slash_commands import extract_command
|
|
|
|
|
|
def map_event_type(event_str: str) -> EventType:
|
|
_map = {
|
|
"MESSAGE": EventType.MESSAGE_RECEIVED,
|
|
"MESSAGE_UPDATE": EventType.MESSAGE_UPDATED,
|
|
"MESSAGE_DELETE": EventType.MESSAGE_DELETED,
|
|
"ADDED_TO_SPACE": EventType.BOT_ADDED,
|
|
"REMOVED_FROM_SPACE": EventType.BOT_REMOVED,
|
|
"CARD_CLICKED": EventType.CARD_ACTION,
|
|
}
|
|
return _map.get(event_str, EventType.MESSAGE_RECEIVED)
|
|
|
|
|
|
def normalize_inbound(
|
|
channel_id: str,
|
|
channel_type: ChannelType,
|
|
raw_payload: dict,
|
|
bot_user: str = "",
|
|
) -> ChannelMessage:
|
|
event = raw_payload.get("event", {})
|
|
event_type_str = event.get("type", "")
|
|
message = event.get("message", {})
|
|
space = event.get("space", {})
|
|
user = event.get("user", {})
|
|
sender = message.get("sender", {})
|
|
|
|
event_type = map_event_type(event_type_str)
|
|
|
|
space_name = space.get("name", "")
|
|
space_type = space.get("spaceType", "SPACE")
|
|
chat_type = _resolve_chat_type(space_type, message, raw_payload, event_type)
|
|
|
|
thread_key = message.get("thread", {}).get("threadKey", "")
|
|
if thread_key:
|
|
chat_type = ChatType.THREAD
|
|
chat_id = f"{space_name}/threads/{thread_key}"
|
|
else:
|
|
chat_id = space_name
|
|
|
|
mentions = parse_mentions(message, bot_user)
|
|
|
|
content = _extract_content(message, event_type, raw_payload)
|
|
msg_type = map_msg_type(message, event_type)
|
|
attachments = extract_attachments(message)
|
|
argument_text = _extract_argument_text(message)
|
|
context = _build_context(channel_id, event, message, space, user, sender)
|
|
route_envelope = resolve_route_envelope(chat_type, space_name, user.get("name", ""))
|
|
|
|
command, command_args = extract_command(content)
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=channel_type,
|
|
channel_user_id=user.get("name", ""),
|
|
channel_chat_id=chat_id,
|
|
channel_message_id=message.get("name", ""),
|
|
),
|
|
chat_type=chat_type,
|
|
event_type=event_type,
|
|
message_type=MessageType.COMMAND if command else msg_type,
|
|
content=content,
|
|
attachments=attachments,
|
|
mentions=mentions,
|
|
metadata={
|
|
"space_name": space_name,
|
|
"space_type": space_type,
|
|
"thread_key": thread_key,
|
|
"sender_name": sender.get("name", ""),
|
|
"sender_type": sender.get("type", ""),
|
|
"argument_text": argument_text,
|
|
"context": context,
|
|
"route_envelope": route_envelope,
|
|
"slash_command": command,
|
|
"slash_args": command_args,
|
|
"is_edited": detect_message_edit(message),
|
|
"is_forwarded": is_forwarded_message(message),
|
|
},
|
|
)
|
|
|
|
|
|
def is_bot_message(message: dict) -> bool:
|
|
sender = message.get("sender", {})
|
|
return sender.get("type", "") == "BOT"
|
|
|
|
|
|
def _extract_argument_text(message: dict) -> str:
|
|
annotations = message.get("annotations", [])
|
|
for annotation in annotations:
|
|
if annotation.get("type") == "SLASH_COMMAND":
|
|
return message.get("argumentText", "")
|
|
return ""
|
|
|
|
|
|
def _build_context(
|
|
channel_id: str,
|
|
event: dict,
|
|
message: dict,
|
|
space: dict,
|
|
user: dict,
|
|
sender: dict,
|
|
) -> dict:
|
|
return {
|
|
"channel": channel_id,
|
|
"accountId": event.get("accountId", ""),
|
|
"messageId": message.get("name", ""),
|
|
"from": sender.get("name", ""),
|
|
"sender": sender,
|
|
"conversation": space,
|
|
"reply": message.get("thread", {}),
|
|
}
|
|
|
|
|
|
def _resolve_chat_type(
|
|
space_type: str,
|
|
message: dict,
|
|
raw_payload: dict,
|
|
event_type: EventType,
|
|
) -> ChatType:
|
|
if space_type == "DIRECT_MESSAGE":
|
|
return ChatType.DIRECT
|
|
if raw_payload.get("is_card_clicked"):
|
|
return ChatType.SPACE
|
|
if event_type == EventType.CARD_ACTION:
|
|
return ChatType.SPACE
|
|
return ChatType.SPACE
|
|
|
|
|
|
def is_forwarded_message(message: dict) -> bool:
|
|
return bool(message.get("retentionSettings")) or bool(message.get("lastUpdateTime") and message.get("createTime"))
|
|
|
|
|
|
def detect_message_edit(message: dict) -> bool:
|
|
create_time = message.get("createTime", "")
|
|
update_time = message.get("lastUpdateTime", "")
|
|
if create_time and update_time and create_time != update_time:
|
|
return True
|
|
return False
|
|
|
|
|
|
def resolve_route_envelope(chat_type: ChatType, space_name: str, user_name: str) -> dict:
|
|
if chat_type == ChatType.DIRECT:
|
|
user_id = user_name.removeprefix("users/") if user_name.startswith("users/") else user_name
|
|
return {
|
|
"peer_kind": "direct",
|
|
"peer_id": user_name,
|
|
"peer_key": f"direct:{user_id}",
|
|
"route": {
|
|
"session_key": f"direct:{user_id}",
|
|
"content": {"type": "direct", "id": user_name},
|
|
},
|
|
}
|
|
space_id = space_name.removeprefix("spaces/") if space_name.startswith("spaces/") else space_name
|
|
return {
|
|
"peer_kind": "group",
|
|
"peer_id": space_name,
|
|
"peer_key": f"space:{space_id}",
|
|
"route": {
|
|
"session_key": f"space:{space_id}",
|
|
"content": {"type": "space", "id": space_name},
|
|
},
|
|
}
|
|
|
|
|
|
def map_msg_type(message: dict, event_type: EventType) -> MessageType:
|
|
if event_type == EventType.CARD_ACTION:
|
|
return MessageType.CARD
|
|
|
|
attachments = message.get("attachment", message.get("attachments", []))
|
|
if attachments:
|
|
first = attachments[0] if attachments else {}
|
|
content_type = first.get("contentType", "")
|
|
if content_type.startswith("image/"):
|
|
return MessageType.IMAGE
|
|
if content_type.startswith("video/"):
|
|
return MessageType.VIDEO
|
|
if content_type.startswith("audio/"):
|
|
return MessageType.AUDIO
|
|
if content_type:
|
|
return MessageType.FILE
|
|
|
|
text = message.get("text", "")
|
|
if message.get("cardsV2") or message.get("cards_v2"):
|
|
return MessageType.CARD
|
|
if text:
|
|
return MessageType.TEXT
|
|
|
|
return MessageType.TEXT
|
|
|
|
|
|
def extract_attachments(message: dict) -> list[Attachment]:
|
|
attachments = message.get("attachment", message.get("attachments", []))
|
|
if not attachments:
|
|
return []
|
|
|
|
result: list[Attachment] = []
|
|
for att in attachments:
|
|
name = att.get("name", "")
|
|
content_name = att.get("contentName", "")
|
|
content_type = att.get("contentType", "")
|
|
|
|
att_type = "file"
|
|
if content_type.startswith("image/"):
|
|
att_type = "image"
|
|
elif content_type.startswith("video/"):
|
|
att_type = "video"
|
|
elif content_type.startswith("audio/"):
|
|
att_type = "audio"
|
|
|
|
result.append(
|
|
Attachment(
|
|
type=att_type,
|
|
filename=content_name,
|
|
mime_type=content_type,
|
|
file_id=_encode_file_id(name),
|
|
metadata={"attachment_name": name},
|
|
)
|
|
)
|
|
return result
|
|
|
|
|
|
def _encode_file_id(attachment_name: str) -> str:
|
|
return json.dumps({"name": attachment_name})
|
|
|
|
|
|
def _extract_content(
|
|
message: dict,
|
|
event_type: EventType,
|
|
raw_payload: dict,
|
|
) -> str:
|
|
if event_type == EventType.CARD_ACTION:
|
|
action = raw_payload.get("action", {})
|
|
return json.dumps(action)
|
|
return message.get("text", "")
|