- 新增钉钉工具调用审计日志功能 - 新增参数校验装饰器与基础工具集(文档、审批、多维表格) - 完善事件类型映射与会话ID生成逻辑 - 新增消息去重、访问策略匹配、配置校验与UI提示 - 新增酷应用卡片、目录管理、表情反应支持 - 优化消息发送逻辑,添加401重试机制 - 新增配置化的权限控制与流式输出支持
260 lines
7.2 KiB
Python
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", ""),
|
|
)
|