325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
|
企业微信通知工具
|
|
|
|
支持两种通知方式:
|
|
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}"
|