Build lightweight AI agent admin
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user