ForcePilot/backend/package/yuxi/channels/adapters/dingding/send.py
Kris 9c01d480cc feat(dingding): 完整实现钉钉适配器功能模块
- 新增钉钉工具调用审计日志功能
- 新增参数校验装饰器与基础工具集(文档、审批、多维表格)
- 完善事件类型映射与会话ID生成逻辑
- 新增消息去重、访问策略匹配、配置校验与UI提示
- 新增酷应用卡片、目录管理、表情反应支持
- 优化消息发送逻辑,添加401重试机制
- 新增配置化的权限控制与流式输出支持
2026-05-13 16:06:10 +08:00

260 lines
7.2 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any
import httpx
from yuxi.channels.models import DeliveryResult
from .token import DingDingTokenManager
logger = logging.getLogger(__name__)
_401_BACKOFF_BASE_S = 1.0
_401_BACKOFF_MAX_S = 30.0
_401_MAX_RETRIES = 3
async def send_text(
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
content: str,
*,
chat_type: str = "direct",
msg_uuid: str | None = None,
thread_root_id: str | None = None,
at_mobiles: list[str] | None = None,
at_user_ids: list[str] | None = None,
is_at_all: bool = False,
http_client: httpx.AsyncClient | None = None,
) -> DeliveryResult:
msg_param: dict[str, Any] = {"content": content}
if at_mobiles:
msg_param["atMobiles"] = at_mobiles
if at_user_ids:
msg_param["atUserIds"] = at_user_ids
if is_at_all:
msg_param["isAtAll"] = True
try:
return await _send_msg(
token_manager,
open_conversation_id,
robot_code,
"sampleText",
msg_param,
chat_type=chat_type,
msg_uuid=msg_uuid,
thread_root_id=thread_root_id,
http_client=http_client,
)
except Exception as e:
logger.error(f"send_text failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_markdown(
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
title: str,
text: str,
*,
chat_type: str = "direct",
msg_uuid: str | None = None,
thread_root_id: str | None = None,
at_mobiles: list[str] | None = None,
at_user_ids: list[str] | None = None,
is_at_all: bool = False,
http_client: httpx.AsyncClient | None = None,
) -> DeliveryResult:
msg_param: dict[str, Any] = {"title": title, "text": text}
if at_mobiles:
msg_param["atMobiles"] = at_mobiles
if at_user_ids:
msg_param["atUserIds"] = at_user_ids
if is_at_all:
msg_param["isAtAll"] = True
try:
return await _send_msg(
token_manager,
open_conversation_id,
robot_code,
"sampleMarkdown",
msg_param,
chat_type=chat_type,
msg_uuid=msg_uuid,
thread_root_id=thread_root_id,
http_client=http_client,
)
except Exception as e:
logger.error(f"send_markdown failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_card(
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
msg_key: str,
msg_param: dict[str, Any],
*,
chat_type: str = "direct",
msg_uuid: str | None = None,
thread_root_id: str | None = None,
http_client: httpx.AsyncClient | None = None,
) -> DeliveryResult:
try:
return await _send_msg(
token_manager,
open_conversation_id,
robot_code,
msg_key,
msg_param,
chat_type=chat_type,
msg_uuid=msg_uuid,
thread_root_id=thread_root_id,
http_client=http_client,
)
except Exception as e:
logger.error(f"send_card failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def _send_msg(
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
msg_key: str,
msg_param: dict[str, Any],
*,
chat_type: str = "direct",
msg_uuid: str | None = None,
thread_root_id: str | None = None,
http_client: httpx.AsyncClient | None = None,
) -> DeliveryResult:
for attempt in range(_401_MAX_RETRIES + 1):
result = await _send_msg_once(
token_manager,
open_conversation_id,
robot_code,
msg_key,
msg_param,
chat_type=chat_type,
msg_uuid=msg_uuid,
thread_root_id=thread_root_id,
http_client=http_client,
)
if result.success:
return result
if not result.error or "401" not in result.error:
return result
if attempt >= _401_MAX_RETRIES:
logger.error(f"[DingDing] _send_msg exhausted 401 retries after {_401_MAX_RETRIES} attempts")
return result
delay = min(_401_BACKOFF_BASE_S * (2 ** attempt), _401_BACKOFF_MAX_S)
logger.warning(
f"[DingDing] 401 Unauthorized, refreshing token and retrying in {delay:.1f}s (attempt {attempt + 1})"
)
token_manager.invalidate()
await asyncio.sleep(delay)
async def _send_msg_once(
token_manager: DingDingTokenManager,
open_conversation_id: str,
robot_code: str,
msg_key: str,
msg_param: dict[str, Any],
*,
chat_type: str = "direct",
msg_uuid: str | None = None,
thread_root_id: str | None = None,
http_client: httpx.AsyncClient | None = None,
) -> DeliveryResult:
token = await token_manager.get_token()
if chat_type == "group":
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
else:
url = "https://api.dingtalk.com/v1.0/robot/privateChatMessages/send"
payload: dict[str, Any] = {
"msgParam": json.dumps(msg_param, ensure_ascii=False),
"msgKey": msg_key,
"openConversationId": open_conversation_id,
"robotCode": robot_code,
"msgUuid": msg_uuid or str(uuid.uuid4()),
}
if thread_root_id:
payload["rootId"] = thread_root_id
headers = {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
if http_client is not None:
resp = await http_client.post(url, json=payload, headers=headers)
else:
async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code != 200:
logger.error(f"_send_msg HTTP {resp.status_code}: {resp.text[:300]} url={url}")
return DeliveryResult(
success=False,
error=f"DingDing API HTTP {resp.status_code}: {resp.text[:200]}",
)
data = resp.json()
return DeliveryResult(
success=True,
message_id=data.get("processQueryKey") or data.get("messageId", ""),
)
async def _send_via_webhook(
http_client: httpx.AsyncClient,
webhook_url: str,
robot_code: str,
content: str,
open_conversation_id: str,
) -> DeliveryResult:
payload = {
"msgKey": "sampleMarkdown",
"msgParam": json.dumps({"content": content}, ensure_ascii=False),
"robotCode": robot_code,
"openConversationId": open_conversation_id,
}
resp = await http_client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=15.0,
)
if resp.status_code != 200:
return DeliveryResult(
success=False,
error=f"Webhook HTTP {resp.status_code}: {resp.text[:200]}",
)
data = resp.json()
return DeliveryResult(
success=True,
message_id=data.get("processQueryKey") or data.get("messageId", ""),
)