ForcePilot/backend/package/yuxi/channels/adapters/googlechat/normalizer.py
Kris ca07c593a8 feat(googlechat): 实现 Google Chat 适配器完整功能集
新增 Google Chat 对接的全套工具模块,包括:
- 会话线程管理、消息解析与格式化
- Pub/Sub 消息解码、提及和命令识别
- 权限审批、目录管理和审计日志
- 消息缓存、媒体上传下载和 SSFR 防护
- 策略配置、卡片构建和流式回复支持
2026-05-12 00:44:18 +08:00

250 lines
7.4 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,
},
)
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", "")