Files
ai-agent-admin/backend-fastapi/app/dingtalk.py
T
2026-06-08 18:14:59 +08:00

565 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
钉钉通知工具
支持三种通知方式:
1. Webhook 群机器人 - 向钉钉群聊发送消息
2. 工作通知 - 通过企业内部应用向个人发送工作通知
3. 待办任务 - 通过钉钉待办 API 创建/管理第三方待办
"""
import base64
import hashlib
import hmac
import logging
import time
import urllib.parse
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class DingTalkWebhook:
"""钉钉群机器人 Webhook"""
@staticmethod
async def _get_config() -> dict:
"""通过 config_manager 三级获取钉钉 Webhook 配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
return {
"webhook_url": group_config.get("webhook_url") or None,
"webhook_secret": group_config.get("webhook_secret") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 Webhook 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.DINGTALK_WEBHOOK_URL)
@staticmethod
async def is_configured_async() -> bool:
"""检查 Webhook 是否已配置(通过 config_manager 三级获取)"""
config = await DingTalkWebhook._get_config()
return bool(config["webhook_url"])
@staticmethod
def _sign_with_secret(secret: str) -> Dict[str, str]:
"""使用指定 secret 生成签名参数"""
if not secret:
return {}
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return {"timestamp": timestamp, "sign": sign}
@staticmethod
async def send_text(content: str, at_mobiles: List[str] = None, at_all: bool = False) -> bool:
"""
发送文本消息
Args:
content: 消息内容
at_mobiles: @指定手机号列表
at_all: 是否@所有人
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "text",
"text": {"content": content},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def send_markdown(title: str, text: str, at_mobiles: List[str] = None, at_all: bool = False) -> bool:
"""
发送 Markdown 消息
Args:
title: 消息标题(会话列表中展示)
text: Markdown 格式内容
at_mobiles: @指定手机号列表
at_all: 是否@所有人
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "markdown",
"markdown": {"title": title, "text": text},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def send_action_card(title: str, text: str, single_url: str = "", single_title: str = "查看详情") -> bool:
"""
发送 ActionCard 消息
Args:
title: 消息标题
text: Markdown 格式内容
single_url: 跳转链接
single_title: 按钮文字
"""
config = await DingTalkWebhook._get_config()
if not config["webhook_url"]:
logger.warning("钉钉 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "actionCard",
"actionCard": {
"title": title,
"text": text,
"singleTitle": single_title,
"singleURL": single_url,
},
}
return await DingTalkWebhook._post(payload, config)
@staticmethod
async def _post(payload: Dict[str, Any], config: dict = None) -> bool:
"""发送请求到钉钉 Webhook"""
try:
if config is None:
config = await DingTalkWebhook._get_config()
url = config["webhook_url"]
sign_params = DingTalkWebhook._sign_with_secret(config.get("webhook_secret") or "")
if sign_params:
separator = "&" if "?" in url else "?"
url = f"{url}{separator}timestamp={sign_params['timestamp']}&sign={sign_params['sign']}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info("钉钉 Webhook 消息发送成功")
return True
else:
logger.error(f"钉钉 Webhook 发送失败: {result}")
return False
except Exception as e:
logger.error(f"钉钉 Webhook 请求异常: {e}")
return False
class DingTalkWorkNotice:
"""钉钉工作通知(企业内部应用)"""
TOKEN_URL = "https://oapi.dingtalk.com/gettoken"
SEND_URL = "https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2"
_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
async def _get_config(cls) -> dict:
"""通过 config_manager 三级获取钉钉工作通知配置"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
return {
"agent_id": group_config.get("agent_id") or None,
"corp_id": group_config.get("corp_id") or None,
"app_id": group_config.get("webhook_url") and None, # webhook_url 不用于工作通知
}
@classmethod
def is_configured(cls) -> bool:
"""检查工作通知是否已配置(仅检查 env,同步兼容)"""
return bool(
settings.DINGTALK_APP_ID
and settings.DINGTALK_APP_SECRET
and settings.DINGTALK_AGENT_ID
)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查工作通知是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
# 工作通知需要 agent_id + OAuth 配置中的 app_id/app_secret
agent_id = group_config.get("agent_id") or None
# app_id/app_secret 来自 oauth_dingtalk 分组
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id") or None
app_secret = oauth_config.get("app_secret") or None
return bool(app_id and app_secret and agent_id)
@classmethod
async def _get_access_token(cls) -> Optional[str]:
"""获取企业内部应用的 access_token(带缓存)"""
now = time.time()
if cls._access_token and now < cls._token_expires_at:
return cls._access_token
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id")
app_secret = oauth_config.get("app_secret")
if not (app_id and app_secret):
logger.warning("钉钉 app_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
cls.TOKEN_URL,
params={
"appkey": app_id,
"appsecret": app_secret,
},
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
cls._access_token = result["access_token"]
cls._token_expires_at = now + result.get("expires_in", 7200) - 300
logger.info("钉钉 access_token 获取成功")
return cls._access_token
else:
logger.error(f"获取钉钉 access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取钉钉 access_token 异常: {e}")
return None
@classmethod
async def send_text(cls, userid_list: List[str], content: str) -> bool:
"""
发送文本工作通知
Args:
userid_list: 钉钉用户 userId 列表(最多100个)
content: 消息内容
"""
return await cls._send(userid_list, {"msgtype": "text", "text": {"content": content}})
@classmethod
async def send_markdown(cls, userid_list: List[str], title: str, text: str) -> bool:
"""
发送 Markdown 工作通知
Args:
userid_list: 钉钉用户 userId 列表
title: 消息标题
text: Markdown 格式内容
"""
return await cls._send(userid_list, {"msgtype": "markdown", "markdown": {"title": title, "text": text}})
@classmethod
async def send_action_card(
cls,
userid_list: List[str],
title: str,
markdown: str,
single_url: str = "",
single_title: str = "查看详情",
) -> bool:
"""
发送 ActionCard 工作通知
Args:
userid_list: 钉钉用户 userId 列表
title: 消息标题
markdown: Markdown 格式内容
single_url: 跳转链接
single_title: 按钮文字
"""
return await cls._send(userid_list, {
"msgtype": "action_card",
"action_card": {
"title": title,
"markdown": markdown,
"single_title": single_title,
"single_url": single_url,
},
})
@classmethod
async def _send(cls, userid_list: List[str], msg: Dict[str, Any]) -> bool:
"""发送工作通知"""
if not await cls.is_configured_async():
logger.warning("钉钉工作通知未配置,跳过发送")
return False
token = await cls._get_access_token()
if not token:
return False
from app.config_manager import config_manager
group_config = await config_manager.get_group("notify_dingtalk")
agent_id = group_config.get("agent_id") or settings.DINGTALK_AGENT_ID
try:
payload = {
"agent_id": agent_id,
"userid_list": ",".join(userid_list[:100]),
"msg": msg,
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.SEND_URL,
params={"access_token": token},
json=payload,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info(f"钉钉工作通知发送成功: {len(userid_list)}")
return True
else:
logger.error(f"钉钉工作通知发送失败: {result}")
return False
except Exception as e:
logger.error(f"钉钉工作通知请求异常: {e}")
return False
class DingTalkTodo:
"""钉钉待办任务(v1.0 API"""
BASE_URL = "https://api.dingtalk.com/v1.0/todo/users"
@classmethod
async def is_configured_async(cls) -> bool:
"""检查待办功能是否已配置(复用 OAuth 配置中的 app_id/app_secret"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_dingtalk")
app_id = oauth_config.get("app_id") or None
app_secret = oauth_config.get("app_secret") or None
return bool(app_id and app_secret)
@classmethod
async def _get_headers(cls) -> Optional[Dict[str, str]]:
"""获取请求头(含 access_token"""
token = await DingTalkWorkNotice._get_access_token()
if not token:
return None
return {
"x-acs-dingtalk-access-token": token,
"Content-Type": "application/json",
}
@classmethod
async def _get_detail_urls(cls, link_type: str, link_id: str) -> Dict[str, str]:
"""根据业务类型和ID拼接待办详情页跳转URL"""
from app.config_manager import config_manager
notify_config = await config_manager.get_group("notify_dingtalk")
pc_base = (notify_config.get("todo_pc_url") or "").rstrip("/")
app_base = (notify_config.get("todo_app_url") or "").rstrip("/")
pc_url = ""
app_url = ""
if link_type == "workflow_task" and link_id:
if pc_base:
pc_url = f"{pc_base}/app/workflow_center/workflow/pending?id={link_id}"
if app_base:
app_url = f"{app_base}/pages-workflow/task-detail?taskId={link_id}"
elif link_type == "workflow_instance" and link_id:
if pc_base:
pc_url = f"{pc_base}/app/workflow_center/workflow/initiated?id={link_id}"
if app_base:
app_url = f"{app_base}/pages-workflow/instance-detail?instanceId={link_id}"
return {"pcUrl": pc_url, "appUrl": app_url}
@classmethod
async def create_todo(
cls,
union_id: str,
subject: str,
description: str = "",
executor_ids: List[str] = None,
due_time: int = None,
priority: int = 20,
source_id: str = None,
link_type: str = "",
link_id: str = "",
) -> Optional[str]:
"""
创建钉钉待办任务
Args:
union_id: 创建者的 unionId
subject: 待办标题(最大1024字符)
description: 备注描述(最大4096字符)
executor_ids: 执行者的 unionId 列表
due_time: 截止时间(Unix 时间戳,单位毫秒)
priority: 优先级 10:较低 20:普通 30:较高 40:紧急
source_id: 业务系统唯一标识
link_type: 关联类型(用于拼接详情页URL)
link_id: 关联对象ID
Returns:
钉钉待办 taskId,失败返回 None
"""
headers = await cls._get_headers()
if not headers:
return None
detail_url = await cls._get_detail_urls(link_type, link_id)
payload: Dict[str, Any] = {
"subject": subject[:1024],
"creatorId": union_id,
"priority": priority,
"notifyConfigs": {
"dingNotify": "1",
},
}
if description:
payload["description"] = description[:4096]
if executor_ids:
payload["executorIds"] = executor_ids[:100]
if due_time:
payload["dueTime"] = due_time
if source_id:
payload["sourceId"] = source_id
if detail_url.get("pcUrl") or detail_url.get("appUrl"):
payload["detailUrl"] = {}
if detail_url.get("pcUrl"):
payload["detailUrl"]["pcUrl"] = detail_url["pcUrl"]
if detail_url.get("appUrl"):
payload["detailUrl"]["appUrl"] = detail_url["appUrl"]
try:
url = f"{cls.BASE_URL}/{union_id}/tasks"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code == 200:
result = resp.json()
task_id = result.get("id")
logger.info(f"钉钉待办创建成功: taskId={task_id}")
return task_id
else:
logger.error(f"钉钉待办创建失败: status={resp.status_code}, body={resp.text}")
return None
except Exception as e:
logger.error(f"钉钉待办创建异常: {e}")
return None
@classmethod
async def update_todo(
cls,
union_id: str,
task_id: str,
subject: str = None,
description: str = None,
done: bool = None,
due_time: int = None,
executor_ids: List[str] = None,
) -> bool:
"""
更新钉钉待办任务
Args:
union_id: 操作者 unionId
task_id: 钉钉待办 taskId
subject: 待办标题
description: 备注描述
done: 完成状态
due_time: 截止时间
executor_ids: 执行者列表
"""
headers = await cls._get_headers()
if not headers:
return False
payload: Dict[str, Any] = {}
if subject is not None:
payload["subject"] = subject[:1024]
if description is not None:
payload["description"] = description[:4096]
if done is not None:
payload["done"] = done
if due_time is not None:
payload["dueTime"] = due_time
if executor_ids is not None:
payload["executorIds"] = executor_ids[:100]
if not payload:
return True
try:
url = f"{cls.BASE_URL}/{union_id}/tasks/{task_id}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(url, headers=headers, json=payload)
if resp.status_code == 200:
logger.info(f"钉钉待办更新成功: taskId={task_id}")
return True
else:
logger.error(f"钉钉待办更新失败: status={resp.status_code}, body={resp.text}")
return False
except Exception as e:
logger.error(f"钉钉待办更新异常: {e}")
return False
@classmethod
async def complete_todo(cls, union_id: str, task_id: str) -> bool:
"""完成钉钉待办"""
return await cls.update_todo(union_id, task_id, done=True)
@classmethod
async def delete_todo(cls, union_id: str, task_id: str) -> bool:
"""删除钉钉待办"""
headers = await cls._get_headers()
if not headers:
return False
try:
url = f"{cls.BASE_URL}/{union_id}/tasks/{task_id}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(url, headers=headers)
if resp.status_code == 200:
logger.info(f"钉钉待办删除成功: taskId={task_id}")
return True
else:
logger.error(f"钉钉待办删除失败: status={resp.status_code}, body={resp.text}")
return False
except Exception as e:
logger.error(f"钉钉待办删除异常: {e}")
return False
def build_dingtalk_markdown(title: str, content: str, app_name: str = None) -> str:
"""
构建钉钉通知的 Markdown 内容
Args:
title: 通知标题
content: 通知内容
app_name: 应用名称
"""
app = app_name or settings.APP_NAME
return f"### {title}\n\n{content}\n\n---\n> 来自 {app}"