ForcePilot/backend/package/yuxi/channel/extensions/dingtalk/monitor.py
Kris 472afc4d1e feat(dingtalk): 新增钉钉渠道插件完整实现
该提交实现了完整的钉钉聊天渠道插件,包含:
1. 基础配置、账号管理与凭证校验
2. WebSocket长连接网关与消息去重
3. 消息接收/解析/分发与安全校验
4. 媒体文件上传下载与缓存
5. 互动卡片流式更新与回调处理
6. 群管理、命令支持与诊断工具
7. 完整的插件元数据与依赖声明
2026-05-21 10:44:03 +08:00

398 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import UTC, datetime
try:
from dingtalk_stream import AckMessage, ChatbotMessage
except ImportError:
AckMessage = None
ChatbotMessage = None
from yuxi.channel.extensions.dingtalk.dedupe import DingTalkDeduplicator
from yuxi.channel.extensions.dingtalk.file_cache import FileCache
logger = logging.getLogger(__name__)
STALE_MSG_AGE_S = 60
BUILTIN_COMMANDS = {
"/help": "查看帮助信息",
"/status": "查看 Bot 运行状态",
"/ping": "测试连通性",
}
class CommandResult:
__slots__ = ("handled", "response")
def __init__(self, handled: bool = False, response: str = ""):
self.handled = handled
self.response = response
class DingTalkMonitor:
def __init__(self):
self._robot_code_cache: str | None = None
self._deduplicator: DingTalkDeduplicator | None = None
self._file_cache: FileCache | None = None
self._security = None
self._pairing = None
self._outbound = None
self._gateway = None
self._dispatch_callback = None
def inject_dependencies(
self,
*,
gateway=None,
outbound=None,
security=None,
pairing=None,
deduplicator: DingTalkDeduplicator | None = None,
file_cache: FileCache | None = None,
) -> None:
self._gateway = gateway
self._outbound = outbound
self._security = security
self._pairing = pairing
self._deduplicator = deduplicator
self._file_cache = file_cache
def set_dispatch_callback(self, callback) -> None:
self._dispatch_callback = callback
async def process(self, callback):
topic = getattr(callback, "topic", "")
if topic and "/card/instances/callback" in topic:
return await self._handle_card_callback(callback)
try:
incoming = ChatbotMessage.from_dict(callback.data)
except Exception:
logger.exception("Failed to parse DingTalk ChatbotMessage")
return AckMessage.STATUS_SYSTEM_EXCEPTION, "ERROR"
self._robot_code_cache = incoming.robot_code
msg_age_s = (time.time() * 1000 - incoming.create_at) / 1000
if msg_age_s > STALE_MSG_AGE_S:
logger.debug("DingTalk stale message ignored, age=%.1fs", msg_age_s)
return AckMessage.STATUS_OK, "OK"
is_group = incoming.conversation_type != "1"
if self._deduplicator:
create_time_s = incoming.create_at / 1000 if incoming.create_at else None
if self._deduplicator.is_duplicate(incoming.message_id, create_time_s):
logger.debug("Duplicate DingTalk message: %s", incoming.message_id)
return AckMessage.STATUS_OK, "OK"
if not is_group and self._deduplicator and self._deduplicator.is_my_msg(incoming):
logger.debug("Filtered own message in direct chat: %s", incoming.message_id)
return AckMessage.STATUS_OK, "OK"
if incoming.message_type == "picture":
image_path = await self._handle_picture(incoming, is_group)
if image_path:
content = f"[图片: {image_path}]"
unified = self._build_unified_message(incoming, content, "IMAGE", is_group)
if self._dispatch_callback:
asyncio.create_task(
self._dispatch_callback(unified),
name=f"dingtalk-dispatch-{incoming.message_id[:8]}",
)
return AckMessage.STATUS_OK, "OK"
content, msg_type = await self._parse_message_content(incoming, is_group)
if content is None:
return AckMessage.STATUS_OK, "OK"
if msg_type == "TEXT" and content:
cmd_result = await self._handle_command(content, incoming, is_group)
if cmd_result.handled:
return AckMessage.STATUS_OK, "OK"
if is_group:
from_user_id = incoming.conversation_id
other_user_id = incoming.conversation_id
actual_user_id = incoming.sender_id
else:
from_user_id = incoming.sender_id
other_user_id = incoming.sender_id
actual_user_id = incoming.sender_id
unified = {
"msg_id": incoming.message_id,
"channel_type": "dingtalk",
"account_id": "default",
"content": content,
"message_type": msg_type,
"sender": {
"id": from_user_id,
"display_name": getattr(incoming, "sender_nick", None) or from_user_id,
"kind": "GROUP" if is_group else "DIRECT",
},
"group": {
"id": incoming.conversation_id,
"type": "group" if is_group else "direct",
}
if is_group
else None,
"timestamp": datetime.fromtimestamp(incoming.create_at / 1000, tz=UTC),
"mentioned_ids": self._extract_mentioned_ids(incoming),
"reply_to_id": None,
"thread_id": None,
"session_key": incoming.conversation_id if is_group else incoming.sender_id,
"raw_payload": incoming,
"metadata": {
"conversation_id": incoming.conversation_id,
"conversation_type": "2" if is_group else "1",
"sender_staff_id": getattr(incoming, "sender_staff_id", ""),
"robot_code": self._robot_code_cache,
"is_group": is_group,
"actual_user_id": actual_user_id,
"other_user_id": other_user_id,
},
}
if self._dispatch_callback:
asyncio.create_task(
self._dispatch_callback(unified),
name=f"dingtalk-dispatch-{incoming.message_id[:8]}",
)
else:
logger.warning("DingTalk: no dispatch callback set, message dropped: %s", incoming.message_id)
return AckMessage.STATUS_OK, "OK"
async def _handle_picture(self, incoming, is_group: bool) -> str | None:
image_list = incoming.get_image_list() if hasattr(incoming, "get_image_list") else []
if not image_list:
return None
download_code = image_list[0]
image_path = await self._download_image(incoming, download_code)
if image_path and self._file_cache:
cache_key = incoming.conversation_id if is_group else incoming.sender_id
self._file_cache.add(cache_key, image_path)
logger.debug("DingTalk image cached: %s -> %s", download_code, image_path)
return image_path
async def _parse_message_content(self, incoming, is_group: bool) -> tuple[str | None, str | None]:
msg_type = incoming.message_type
if msg_type == "text":
text = getattr(incoming, "text", None)
content = text.content.strip() if text else ""
if self._file_cache:
cache_key = incoming.conversation_id if is_group else incoming.sender_id
cached = self._file_cache.get(cache_key)
if cached:
content = f"{content}\n[图片: {cached.file_path}]" if content else f"[图片: {cached.file_path}]"
self._file_cache.clear(cache_key)
return content, "TEXT"
elif msg_type == "audio":
extensions = getattr(incoming, "extensions", {})
recognition = extensions.get("content", {}).get("recognition", "")
return recognition, "TEXT"
elif msg_type == "richText":
text_list = incoming.get_text_list() if hasattr(incoming, "get_text_list") else []
image_list = incoming.get_image_list() if hasattr(incoming, "get_image_list") else []
text_content = "".join(text_list).strip()
image_paths = []
for download_code in image_list:
path = await self._download_image(incoming, download_code)
if path:
image_paths.append(path)
if image_paths:
image_lines = "\n".join(f"[图片: {p}]" for p in image_paths)
text_content = f"{text_content}\n{image_lines}" if text_content else image_lines
return text_content or None, "TEXT"
elif msg_type == "video":
download_code = getattr(incoming, "download_code", None) or getattr(incoming, "video_download_code", None)
if download_code:
video_path = await self._download_image(incoming, download_code)
if video_path:
return f"[视频: {video_path}]", "VIDEO"
return "[视频消息]", "VIDEO"
elif msg_type == "file":
file_name = getattr(incoming, "file_name", "") or "未知文件"
download_code = getattr(incoming, "download_code", None) or getattr(incoming, "file_download_code", None)
if download_code:
file_path = await self._download_image(incoming, download_code)
if file_path:
return f"[文件: {file_name} -> {file_path}]", "FILE"
return f"[文件: {file_name}]", "FILE"
return None, None
async def _download_image(self, incoming, download_code: str) -> str | None:
if not self._gateway or not self._gateway.token_manager:
return None
http = self._gateway.token_manager._http
if http is None:
async with __import__("httpx").AsyncClient(timeout=30.0) as http:
return await self._do_download(http, download_code)
return await self._do_download(http, download_code)
def _extract_mentioned_ids(self, incoming) -> list[str]:
at_users = getattr(incoming, "at_users", None)
if at_users:
return [u.get("dingtalkId", "") for u in at_users if u.get("dingtalkId")]
raw = getattr(incoming, "raw_payload", None)
if raw and isinstance(raw, dict):
at_users_raw = raw.get("atUsers", [])
return [u.get("dingtalkId", "") for u in at_users_raw if u.get("dingtalkId")]
return []
def _build_unified_message(self, incoming, content: str, msg_type: str, is_group: bool) -> dict:
if is_group:
from_user_id = incoming.conversation_id
other_user_id = incoming.conversation_id
actual_user_id = incoming.sender_id
else:
from_user_id = incoming.sender_id
other_user_id = incoming.sender_id
actual_user_id = incoming.sender_id
return {
"msg_id": incoming.message_id,
"channel_type": "dingtalk",
"account_id": "default",
"content": content,
"message_type": msg_type,
"sender": {
"id": from_user_id,
"display_name": getattr(incoming, "sender_nick", None) or from_user_id,
"kind": "GROUP" if is_group else "DIRECT",
},
"group": {
"id": incoming.conversation_id,
"type": "group" if is_group else "direct",
}
if is_group
else None,
"timestamp": datetime.fromtimestamp(incoming.create_at / 1000, tz=UTC),
"mentioned_ids": self._extract_mentioned_ids(incoming),
"reply_to_id": None,
"thread_id": None,
"session_key": incoming.conversation_id if is_group else incoming.sender_id,
"raw_payload": incoming,
"metadata": {
"conversation_id": incoming.conversation_id,
"conversation_type": "2" if is_group else "1",
"sender_staff_id": getattr(incoming, "sender_staff_id", ""),
"robot_code": self._robot_code_cache,
"is_group": is_group,
"actual_user_id": actual_user_id,
"other_user_id": other_user_id,
},
}
async def _handle_card_callback(self, callback) -> tuple[str, str]:
data = callback.data if hasattr(callback, "data") else {}
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
data = {}
card_instance_id = data.get("cardInstanceId", "")
out_track_id = data.get("outTrackId", "")
button_key = data.get("buttonKey", "")
button_value = data.get("buttonValue", "")
user_id = data.get("userId", "")
conversation_id = data.get("conversationId", "")
logger.info(
"DingTalk card callback: card=%s button=%s value=%s user=%s",
card_instance_id,
button_key,
button_value,
user_id,
)
unified = {
"msg_id": out_track_id or card_instance_id,
"channel_type": "dingtalk",
"account_id": "default",
"content": f"[卡片回调] buttonKey={button_key} buttonValue={button_value}",
"message_type": "CARD_CALLBACK",
"sender": {"id": user_id, "display_name": user_id, "kind": "DIRECT"},
"group": {"id": conversation_id, "type": "group"} if conversation_id else None,
"timestamp": datetime.now(UTC),
"mentioned_ids": [],
"reply_to_id": None,
"thread_id": None,
"session_key": conversation_id or user_id,
"raw_payload": data,
"metadata": {
"card_instance_id": card_instance_id,
"out_track_id": out_track_id,
"button_key": button_key,
"button_value": button_value,
"conversation_id": conversation_id,
"user_id": user_id,
"is_card_callback": True,
},
}
if self._dispatch_callback:
asyncio.create_task(
self._dispatch_callback(unified),
name=f"dingtalk-card-{out_track_id[:8] if out_track_id else 'unknown'}",
)
return AckMessage.STATUS_OK, "OK"
async def _handle_command(self, text: str, incoming, is_group: bool) -> CommandResult:
if not text.startswith("/"):
return CommandResult()
parts = text.strip().split(maxsplit=1)
cmd = parts[0].lower()
if cmd == "/help":
help_lines = ["**可用命令**:"] + [f"- `{k}`: {v}" for k, v in BUILTIN_COMMANDS.items()]
response = "\n".join(help_lines)
elif cmd == "/ping":
response = "pong 🏓"
elif cmd == "/status":
response = "运行正常 ✅"
else:
response = f"未知命令: `{cmd}`,输入 `/help` 查看帮助"
if self._outbound:
try:
await self._outbound.send_text(
incoming.sender_id,
response,
is_group=is_group,
conversation_id=incoming.conversation_id if is_group else "",
sender_staff_id=getattr(incoming, "sender_staff_id", ""),
)
except Exception:
logger.exception("DingTalk command reply failed")
return CommandResult(handled=True, response=response)
async def _do_download(self, http, download_code: str) -> str | None:
token = await self._gateway.token_manager.get_access_token()
if not token:
return None
from yuxi.channel.extensions.dingtalk.media import download_image
robot_code = self._robot_code_cache or self._gateway.robot_code or ""
return await download_image(http, token, download_code, robot_code)