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
+324
View File
@@ -0,0 +1,324 @@
"""
企业微信通知工具
支持两种通知方式:
1. Webhook 群机器人 - 向企业微信群聊发送消息
2. 应用消息 - 通过企业自建应用向个人发送消息(复用 OAuth 配置)
"""
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class WecomWebhook:
"""企业微信群机器人 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_wecom")
return {
"webhook_url": group_config.get("webhook_url") or None,
}
@staticmethod
def is_configured() -> bool:
"""检查 Webhook 是否已配置(仅检查 env,同步兼容)"""
return bool(settings.WECOM_WEBHOOK_URL)
@staticmethod
async def is_configured_async() -> bool:
"""检查 Webhook 是否已配置(通过 config_manager 三级获取)"""
config = await WecomWebhook._get_config()
return bool(config["webhook_url"])
@staticmethod
async def send_text(content: str, mentioned_list: List[str] = None, mentioned_mobile_list: List[str] = None) -> bool:
"""
发送文本消息
Args:
content: 消息内容(最长2048字节)
mentioned_list: @指定用户的 userid 列表,@all 表示所有人
mentioned_mobile_list: @指定手机号列表
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_list": mentioned_list or [],
"mentioned_mobile_list": mentioned_mobile_list or [],
},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def send_markdown(content: str) -> bool:
"""
发送 Markdown 消息
Args:
content: Markdown 格式内容(最长4096字节)
支持语法: 标题(#)、加粗(**)、链接、引用(>)、字体颜色(<font>)
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
payload: Dict[str, Any] = {
"msgtype": "markdown",
"markdown": {"content": content},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def send_news(title: str, description: str = "", url: str = "", picurl: str = "") -> bool:
"""
发送图文消息
Args:
title: 标题(不超过128字节)
description: 描述(不超过512字节)
url: 点击跳转链接
picurl: 图片链接
"""
config = await WecomWebhook._get_config()
if not config["webhook_url"]:
logger.warning("企业微信 Webhook 未配置,跳过发送")
return False
article: Dict[str, Any] = {"title": title}
if description:
article["description"] = description
if url:
article["url"] = url
if picurl:
article["picurl"] = picurl
payload: Dict[str, Any] = {
"msgtype": "news",
"news": {"articles": [article]},
}
return await WecomWebhook._post(payload, config)
@staticmethod
async def _post(payload: Dict[str, Any], config: dict = None) -> bool:
"""发送请求到企业微信 Webhook"""
try:
if config is None:
config = await WecomWebhook._get_config()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(config["webhook_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 WecomAppMessage:
"""企业微信应用消息(企业自建应用,复用 OAuth 配置)"""
TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
SEND_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
_access_token: Optional[str] = None
_token_expires_at: float = 0
@classmethod
def is_configured(cls) -> bool:
"""检查应用消息是否已配置(仅检查 env,同步兼容)"""
return bool(
settings.WECOM_CORP_ID
and settings.WECOM_APP_SECRET
and settings.WECOM_AGENT_ID
)
@classmethod
async def is_configured_async(cls) -> bool:
"""检查应用消息是否已配置(通过 config_manager 三级获取)"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
return bool(
oauth_config.get("corp_id")
and oauth_config.get("app_secret")
and oauth_config.get("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_wecom")
corp_id = oauth_config.get("corp_id")
app_secret = oauth_config.get("app_secret")
if not (corp_id and app_secret):
logger.warning("企业微信 corp_id/app_secret 未配置")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
cls.TOKEN_URL,
params={
"corpid": corp_id,
"corpsecret": 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 列表(最多1000个,用|分隔)
content: 消息内容
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "text",
"agentid": int(agent_id),
"text": {"content": content},
}
return await cls._send(msg)
@classmethod
async def send_textcard(
cls,
userid_list: List[str],
title: str,
description: str,
url: str = "",
btntxt: str = "详情",
) -> bool:
"""
发送文本卡片消息给个人
Args:
userid_list: 企业微信 userid 列表
title: 标题(不超过128字节)
description: 描述(支持<div>标签)
url: 点击跳转链接
btntxt: 按钮文字
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "textcard",
"agentid": int(agent_id),
"textcard": {
"title": title,
"description": description,
"url": url or "URL",
"btntxt": btntxt,
},
}
return await cls._send(msg)
@classmethod
async def send_markdown(cls, userid_list: List[str], content: str) -> bool:
"""
发送 Markdown 消息给个人
Args:
userid_list: 企业微信 userid 列表
content: Markdown 格式内容
"""
from app.config_manager import config_manager
oauth_config = await config_manager.get_group("oauth_wecom")
agent_id = oauth_config.get("agent_id") or settings.WECOM_AGENT_ID
msg = {
"touser": "|".join(userid_list[:1000]),
"msgtype": "markdown",
"agentid": int(agent_id),
"markdown": {"content": content},
}
return await cls._send(msg)
@classmethod
async def _send(cls, 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
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
cls.SEND_URL,
params={"access_token": token},
json=msg,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") == 0:
logger.info(f"企业微信应用消息发送成功")
return True
else:
logger.error(f"企业微信应用消息发送失败: {result}")
return False
except Exception as e:
logger.error(f"企业微信应用消息请求异常: {e}")
return False
def build_wecom_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{content}\n> 来自 {app}"