36ba9338f1
wecom.py: - Token 自动续期 (7200s 过期 + 60s 提前量 + 42001 重试) - POST 请求自动重试 (最多 3 次,含速率限制退避) - 新增 send_markdown_message() / send_template_card() 富消息方法 wecom.py API: - GET /api/wecom/callback: 企微回调 URL 验证 (SHA1 签名 + AES 解密) - POST /api/wecom/callback: 事件接收占位 scheduler.py: - 仅推送给已绑定 wecom_userid 的经理 - 消息内容优化 (已填报/未填报人数统计) main.py: - APScheduler 注册每日 17:30 自动检查填报 已验证: WECOM_TOKEN 获取成功 Co-Authored-By: Claude <noreply@anthropic.com>
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
import time
|
|
import httpx
|
|
from app.config import settings
|
|
|
|
|
|
class WecomClient:
|
|
"""WeChat Work API client — token management, message sending, OAuth."""
|
|
|
|
def __init__(self):
|
|
self.corp_id = settings.WECOM_CORP_ID
|
|
self.agent_id = settings.WECOM_AGENT_ID
|
|
self.secret = settings.WECOM_SECRET
|
|
self._access_token: str | None = None
|
|
self._token_expires_at: float = 0 # epoch seconds
|
|
|
|
async def _get_token(self) -> str:
|
|
"""Get a valid access token, refreshing if expired."""
|
|
# Token valid for 7200s; refresh 60s early to be safe
|
|
if self._access_token and time.time() < self._token_expires_at - 60:
|
|
return self._access_token
|
|
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(url, timeout=10)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
self._access_token = data["access_token"]
|
|
self._token_expires_at = time.time() + data.get("expires_in", 7200)
|
|
return self._access_token
|
|
raise Exception(f"Failed to get wecom token: {data}")
|
|
|
|
async def _post_with_retry(self, url: str, body: dict, max_retries: int = 3) -> dict:
|
|
"""POST with retry on network errors and automatic token refresh on 42001."""
|
|
last_error = None
|
|
for attempt in range(max_retries):
|
|
try:
|
|
token = await self._get_token()
|
|
full_url = f"{url}?access_token={token}"
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(full_url, json=body, timeout=10)
|
|
data = resp.json()
|
|
errcode = data.get("errcode", 0)
|
|
|
|
if errcode == 0:
|
|
return data
|
|
|
|
# Token expired mid-request — clear and retry once
|
|
if errcode == 42001:
|
|
self._access_token = None
|
|
self._token_expires_at = 0
|
|
if attempt < max_retries - 1:
|
|
continue
|
|
|
|
# Rate limit — wait and retry
|
|
if errcode == 45009:
|
|
if attempt < max_retries - 1:
|
|
await httpx.AsyncClient().aclose()
|
|
time.sleep(1 * (attempt + 1))
|
|
continue
|
|
|
|
last_error = data
|
|
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
|
last_error = {"errcode": -1, "errmsg": str(e)}
|
|
if attempt < max_retries - 1:
|
|
time.sleep(0.5 * (attempt + 1))
|
|
|
|
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
|
|
|
async def get_userinfo_by_code(self, code: str) -> dict | None:
|
|
"""Exchange OAuth2 code for userid (used in silent login)."""
|
|
try:
|
|
token = await self._get_token()
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(url, timeout=10)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return data
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
|
|
"""Send a text app message to specified users."""
|
|
if not settings.WECOM_AGENT_ID:
|
|
return False
|
|
try:
|
|
body = {
|
|
"touser": "|".join(user_ids),
|
|
"msgtype": "text",
|
|
"agentid": int(settings.WECOM_AGENT_ID),
|
|
"text": {"content": content},
|
|
}
|
|
await self._post_with_retry(
|
|
"https://qyapi.weixin.qq.com/cgi-bin/message/send", body
|
|
)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def send_markdown_message(self, content: str) -> bool:
|
|
"""Send a markdown message to all users in the app (broadcast)."""
|
|
if not settings.WECOM_AGENT_ID:
|
|
return False
|
|
try:
|
|
body = {
|
|
"touser": "@all",
|
|
"msgtype": "markdown",
|
|
"agentid": int(settings.WECOM_AGENT_ID),
|
|
"markdown": {"content": content},
|
|
}
|
|
await self._post_with_retry(
|
|
"https://qyapi.weixin.qq.com/cgi-bin/message/send", body
|
|
)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def send_template_card(
|
|
self,
|
|
user_ids: list[str],
|
|
title: str,
|
|
desc: str,
|
|
url: str,
|
|
btn_text: str = "查看详情",
|
|
) -> bool:
|
|
"""Send a text_notice template card with a deep-link button."""
|
|
if not settings.WECOM_AGENT_ID:
|
|
return False
|
|
try:
|
|
body = {
|
|
"touser": "|".join(user_ids),
|
|
"msgtype": "template_card",
|
|
"agentid": int(settings.WECOM_AGENT_ID),
|
|
"template_card": {
|
|
"card_type": "text_notice",
|
|
"main_title": {"title": title, "desc": desc},
|
|
"card_action": {
|
|
"type": 1, # jump to URL
|
|
"url": url,
|
|
},
|
|
"button_list": [
|
|
{
|
|
"text": btn_text,
|
|
"style": 1, # primary
|
|
"key": "open_url",
|
|
}
|
|
],
|
|
},
|
|
}
|
|
await self._post_with_retry(
|
|
"https://qyapi.weixin.qq.com/cgi-bin/message/send", body
|
|
)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
wecom_client = WecomClient()
|