ForcePilot/backend/package/yuxi/channels/adapters/dingding/normalizer.py
Kris 86105d163d feat(dingding): add dingtalk channel adapter implementation
实现钉钉官方的频道适配器,包含完整的消息收发、事件处理、媒体上传下载、流式响应以及webhook支持,覆盖了认证、签名校验、速率限制、健康检查等完整功能流程。
2026-05-12 00:43:14 +08:00

213 lines
6.2 KiB
Python

from __future__ import annotations
import json
from typing import Any
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
from .session import generate_dingding_chat_id, resolve_dingding_chat_type
def map_event_type(raw: dict) -> EventType:
if raw.get("event_type") == "card_action":
return EventType.CARD_ACTION
if raw.get("action_from") == "card_callback":
return EventType.CARD_ACTION
if raw.get("robotCode"):
return EventType.BOT_ADDED
return EventType.MESSAGE_RECEIVED
def map_msg_type(raw: dict) -> MessageType:
msg_type = raw.get("msgtype", "text")
_map: dict[str, MessageType] = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"file": MessageType.FILE,
"voice": MessageType.AUDIO,
"video": MessageType.VIDEO,
"markdown": MessageType.TEXT,
"link": MessageType.TEXT,
"actionCard": MessageType.CARD,
"feedCard": MessageType.CARD,
"oa": MessageType.TEXT,
"sticker": MessageType.STICKER,
}
return _map.get(msg_type, MessageType.TEXT)
def _parse_content(content: Any) -> dict:
if isinstance(content, dict):
return content
if isinstance(content, str):
try:
return json.loads(content)
except (json.JSONDecodeError, TypeError):
return {}
return {}
def _describe_media(msg_type: str, parsed: dict, raw: dict | None = None) -> str:
if msg_type == "image":
return "[图片]"
if msg_type == "file":
filename = parsed.get("fileName", parsed.get("file_name", ""))
if not filename and raw:
filename = raw.get("fileName", "")
return f"[文件: {filename}]" if filename else "[文件]"
if msg_type == "voice":
return "[语音]"
if msg_type == "video":
return "[视频]"
if msg_type == "sticker":
return "[贴纸]"
return ""
def extract_text(raw: dict) -> str:
msg_type = raw.get("msgtype", "text")
text_block = raw.get("text", {})
if msg_type in ("image", "file", "voice", "video", "sticker"):
parsed = _parse_content(raw.get("content", text_block))
return _describe_media(msg_type, parsed, raw)
if isinstance(text_block, dict):
return text_block.get("content", "")
if isinstance(text_block, str):
return text_block
try:
return str(text_block) if text_block else ""
except Exception:
return ""
def extract_attachments(raw: dict) -> list[Attachment]:
msg_type = raw.get("msgtype", "text")
content = raw.get("content", {})
if msg_type == "image":
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
if download_code:
return [Attachment(type="image", file_id=download_code)]
return []
if msg_type == "file":
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
if download_code:
return [
Attachment(
type="file",
file_id=download_code,
filename=raw.get("fileName", ""),
)
]
return []
if msg_type == "voice":
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
if download_code:
return [Attachment(type="audio", file_id=download_code)]
return []
if msg_type == "video":
download_code = raw.get("downloadCode", "") or raw.get("download_code", "")
if download_code:
return [Attachment(type="video", file_id=download_code)]
return []
if msg_type == "sticker":
parsed = _parse_content(content)
download_code = parsed.get("downloadCode", "") or parsed.get("download_code", "")
if download_code:
return [Attachment(type="sticker", file_id=download_code)]
return []
return []
def extract_mentions(raw: dict) -> MentionsInfo | None:
if not raw.get("isGroupChat"):
return None
mentioned_ids: list[str] = []
is_at_all = raw.get("isAtAll", False)
at_users = raw.get("atUsers", [])
if isinstance(at_users, list):
for u in at_users:
if isinstance(u, dict):
uid = u.get("dingtalkId", "")
if uid:
mentioned_ids.append(uid)
if is_at_all:
mentioned_ids.append("@all")
raw_text = extract_text(raw).strip()
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=bool(mentioned_ids),
raw_text=raw_text,
)
def normalize_inbound(
channel_id: str,
channel_type: ChannelType,
raw_payload: dict[str, Any],
) -> ChannelMessage:
chat_type_str = resolve_dingding_chat_type(raw_payload)
chat_type = ChatType(chat_type_str)
chat_id = generate_dingding_chat_id(raw_payload, chat_type_str)
sender_id = (
raw_payload.get("senderId", "")
or raw_payload.get("senderStaffId", "")
or raw_payload.get("senderNick", "")
or "unknown"
)
message_id = raw_payload.get("msgId", "") or raw_payload.get("openMsgId", "")
event_type = map_event_type(raw_payload)
msg_type = map_msg_type(raw_payload)
content = extract_text(raw_payload)
attachments = extract_attachments(raw_payload)
mentions = extract_mentions(raw_payload)
metadata: dict[str, Any] = {
"msgtype": raw_payload.get("msgtype", "text"),
"isGroupChat": raw_payload.get("isGroupChat", False),
}
if raw_payload.get("rootId"):
metadata["root_id"] = raw_payload["rootId"]
if raw_payload.get("conversationId"):
metadata["conversation_id"] = raw_payload["conversationId"]
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=channel_type,
channel_user_id=sender_id,
channel_chat_id=chat_id,
channel_message_id=message_id,
),
event_type=event_type,
message_type=msg_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
metadata=metadata,
)