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

174 lines
5.1 KiB
Python

from __future__ import annotations
import json
import logging
import uuid
import httpx
logger = logging.getLogger(__name__)
CREATE_AND_DELIVER_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
STREAMING_UPDATE_URL = "https://api.dingtalk.com/v1.0/card/streamingUpdate"
class DingTalkCardManager:
def __init__(self, gateway, http: httpx.AsyncClient | None = None):
self._gateway = gateway
self._http = http
self._card_instance_id: str | None = None
self._out_track_id: str | None = None
def set_http(self, http: httpx.AsyncClient) -> None:
self._http = http
async def _get_http(self) -> httpx.AsyncClient:
if self._http is not None:
return self._http
client = httpx.AsyncClient(timeout=15.0)
self._http = client
return client
async def create_and_deliver(
self,
open_conversation_id: str,
robot_code: str,
card_template_id: str,
title: str = "AI 正在思考...",
) -> bool:
token = await self._gateway.token_manager.get_access_token()
if not token:
return False
self._out_track_id = str(uuid.uuid4())
body = {
"cardTemplateId": card_template_id,
"outTrackId": self._out_track_id,
"robotCode": robot_code,
"openConversationId": open_conversation_id,
"cardData": json.dumps({"title": title, "content": ""}),
"imGroupOpenDeliverModel": {"robotCode": robot_code},
"callbackType": "STREAM",
}
http = await self._get_http()
resp = await http.post(
CREATE_AND_DELIVER_URL,
headers={
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
},
json=body,
)
data = resp.json()
self._card_instance_id = data.get("cardInstanceId")
return bool(self._card_instance_id)
async def streaming_update(self, content: str, status: str = "PROCESSING") -> bool:
if not self._card_instance_id:
return False
token = await self._gateway.token_manager.get_access_token()
if not token:
return False
body = {
"outTrackId": self._out_track_id,
"cardInstanceId": self._card_instance_id,
"content": content,
"fullContent": False,
"flowStatus": status,
}
http = await self._get_http()
resp = await http.put(
STREAMING_UPDATE_URL,
headers={
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
},
json=body,
)
return resp.status_code == 200
def build_approval_card_buttons(request_id: str) -> list[dict]:
return [
{
"title": "批准",
"actionURL": f"dingtalk://yuxi/approval/approve?req={request_id}",
},
{
"title": "拒绝",
"actionURL": f"dingtalk://yuxi/approval/deny?req={request_id}",
},
]
def build_action_card(title: str, text: str, buttons: list[dict], btn_orientation: str = "1") -> dict:
return {
"msgtype": "actionCard",
"actionCard": {
"title": title,
"text": text,
"btnOrientation": btn_orientation,
"btns": buttons,
},
}
def build_markdown_message(title: str, text: str) -> dict:
return {
"msgtype": "markdown",
"markdown": {
"title": title,
"text": text,
},
}
def build_image_reply_button(image_url: str, prompt_en: str = "") -> dict | None:
if not image_url or not prompt_en:
return None
return {
"title": "查看原图",
"actionURL": image_url,
}
def apply_ai_card_monkey_patch():
try:
from dingtalk_stream import AICardReplier, AICardStatus, CardReplier
class CustomAICardReplier(CardReplier):
async def start(self, card_instance_id: str) -> None:
self._card_instance_id = card_instance_id
await self._update_card(
card_instance_id,
{
"flowStatus": AICardStatus.PROCESSING,
"content": "",
},
)
async def update_content(self, content: str) -> None:
await self._update_card(
self._card_instance_id,
{
"content": content,
"flowStatus": AICardStatus.PROCESSING,
},
)
async def finish(self) -> None:
await self._update_card(
self._card_instance_id,
{"flowStatus": AICardStatus.FINISHED},
)
AICardReplier.start = CustomAICardReplier.start
logger.info("DingTalk AICardReplier monkey patch applied")
except ImportError:
logger.debug("dingtalk_stream SDK not available, skipping monkey patch")