Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+564
View File
@@ -0,0 +1,564 @@
"""
钉钉通知工具
支持三种通知方式:
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}"