Files
gaokao-volunteer-system/data/channel_sync/wecom_adapter.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

339 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""企业微信最小集成层T8.3
目标:不依赖第三方企业微信 SDK仅用标准库封装两类常用能力
- 通知机器人 webhook 文本消息(/cgi-bin/webhook/send
- 应用消息文本推送(/cgi-bin/message/send
设计取向:
- 机器人与应用消息分离建模,避免混淆不同鉴权方式
- 应用 access_token 自动缓存与过期刷新
- 纯标准库 HTTP 传输,便于单测注入 fake transport
- 上游 errcode / HTTP 状态 / transport 异常统一包装为结构化异常
"""
from __future__ import annotations
import json
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from urllib import parse
from .wechat_adapter import AccessToken, Transport, UrllibTransport
DEFAULT_API_BASE = "https://qyapi.weixin.qq.com"
DEFAULT_TIMEOUT_SECONDS = 5.0
DEFAULT_TOKEN_REFRESH_SKEW_SECONDS = 60.0
class WeComConfigError(ValueError):
"""企业微信配置缺失或非法。"""
class WeComAPIError(RuntimeError):
"""企业微信接口返回错误。"""
def __init__(
self,
message: str,
*,
errcode: Optional[int] = None,
errmsg: Optional[str] = None,
http_status: Optional[int] = None,
endpoint: Optional[str] = None,
) -> None:
super().__init__(message)
self.errcode = errcode
self.errmsg = errmsg
self.http_status = http_status
self.endpoint = endpoint
@dataclass(frozen=True)
class WeComBotConfig:
webhook_key: str
api_base: str = DEFAULT_API_BASE
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
def __post_init__(self) -> None:
if not self.webhook_key:
raise WeComConfigError("GAOKAO_WECOM_BOT_KEY 未设置")
if self.timeout_seconds <= 0:
raise WeComConfigError("timeout_seconds 必须大于 0")
@dataclass(frozen=True)
class WeComAppConfig:
corp_id: str
corp_secret: str
agent_id: int
api_base: str = DEFAULT_API_BASE
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
token_refresh_skew_seconds: float = DEFAULT_TOKEN_REFRESH_SKEW_SECONDS
def __post_init__(self) -> None:
if not self.corp_id:
raise WeComConfigError("GAOKAO_WECOM_CORP_ID 未设置")
if not self.corp_secret:
raise WeComConfigError("GAOKAO_WECOM_CORP_SECRET 未设置")
if self.agent_id <= 0:
raise WeComConfigError("GAOKAO_WECOM_AGENT_ID 必须为正整数")
if self.timeout_seconds <= 0:
raise WeComConfigError("timeout_seconds 必须大于 0")
if self.token_refresh_skew_seconds < 0:
raise WeComConfigError("token_refresh_skew_seconds 不能为负")
class _WeComBaseClient:
def __init__(
self,
*,
api_base: str,
timeout_seconds: float,
transport: Optional[Transport] = None,
) -> None:
self._api_base = api_base
self._timeout_seconds = timeout_seconds
self._transport = transport or UrllibTransport()
def _api_url(self, path: str) -> str:
base = self._api_base.rstrip("/")
return f"{base}/{path.lstrip('/')}"
def _request_json(
self,
method: str,
path: str,
*,
query: Optional[dict[str, Any]] = None,
payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
url = self._api_url(path)
if query:
url = f"{url}?{parse.urlencode(query)}"
body = None
headers: dict[str, str] = {}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json; charset=utf-8"
try:
status, raw = self._transport.request(
method.upper(),
url,
headers=headers,
body=body,
timeout=self._timeout_seconds,
)
except Exception as e:
raise WeComAPIError(
f"企业微信接口 transport_error: {e}",
endpoint=path,
) from e
try:
data = json.loads(raw.decode("utf-8") or "{}")
except Exception as e: # pragma: no cover - defensive
raise WeComAPIError(
f"企业微信接口返回非 JSON: {e}", http_status=status, endpoint=path
) from e
if status >= 400:
raise WeComAPIError(
f"企业微信接口 HTTP {status}",
http_status=status,
endpoint=path,
errmsg=data.get("errmsg") if isinstance(data, dict) else None,
errcode=data.get("errcode") if isinstance(data, dict) else None,
)
if not isinstance(data, dict):
raise WeComAPIError(
"企业微信接口返回结构非法",
http_status=status,
endpoint=path,
)
errcode = data.get("errcode", 0)
if errcode not in (0, None):
raise WeComAPIError(
f"企业微信接口错误: {errcode} {data.get('errmsg', '')}".strip(),
errcode=int(errcode),
errmsg=str(data.get("errmsg", "")),
http_status=status,
endpoint=path,
)
return data
class WeComBotClient(_WeComBaseClient):
def __init__(
self, *, config: WeComBotConfig, transport: Optional[Transport] = None
) -> None:
super().__init__(
api_base=config.api_base,
timeout_seconds=config.timeout_seconds,
transport=transport,
)
self._config = config
@classmethod
def from_env(cls, *, transport: Optional[Transport] = None) -> "WeComBotClient":
return cls(
config=WeComBotConfig(
webhook_key=os.environ.get("GAOKAO_WECOM_BOT_KEY", ""),
api_base=os.environ.get("GAOKAO_WECOM_API_BASE", DEFAULT_API_BASE),
timeout_seconds=float(
os.environ.get(
"GAOKAO_WECOM_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS
)
),
),
transport=transport,
)
def send_text(
self,
*,
content: str,
mentioned_list: Optional[list[str]] = None,
mentioned_mobile_list: Optional[list[str]] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"msgtype": "text",
"text": {"content": content},
}
if mentioned_list:
payload["text"]["mentioned_list"] = list(mentioned_list)
if mentioned_mobile_list:
payload["text"]["mentioned_mobile_list"] = list(mentioned_mobile_list)
return self._request_json(
"POST",
"/cgi-bin/webhook/send",
query={"key": self._config.webhook_key},
payload=payload,
)
class WeComAppClient(_WeComBaseClient):
def __init__(
self,
*,
config: WeComAppConfig,
transport: Optional[Transport] = None,
clock: Optional[Callable[[], float]] = None,
) -> None:
super().__init__(
api_base=config.api_base,
timeout_seconds=config.timeout_seconds,
transport=transport,
)
self._config = config
self._clock = clock or time.time
self._access_token: Optional[AccessToken] = None
@classmethod
def from_env(
cls,
*,
transport: Optional[Transport] = None,
clock: Optional[Callable[[], float]] = None,
) -> "WeComAppClient":
return cls(
config=WeComAppConfig(
corp_id=os.environ.get("GAOKAO_WECOM_CORP_ID", ""),
corp_secret=os.environ.get("GAOKAO_WECOM_CORP_SECRET", ""),
agent_id=int(os.environ.get("GAOKAO_WECOM_AGENT_ID", "0")),
api_base=os.environ.get("GAOKAO_WECOM_API_BASE", DEFAULT_API_BASE),
timeout_seconds=float(
os.environ.get(
"GAOKAO_WECOM_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS
)
),
token_refresh_skew_seconds=float(
os.environ.get(
"GAOKAO_WECOM_TOKEN_REFRESH_SKEW_SECONDS",
DEFAULT_TOKEN_REFRESH_SKEW_SECONDS,
)
),
),
transport=transport,
clock=clock,
)
def _now(self) -> float:
return float(self._clock())
def _get_access_token(self, *, force_refresh: bool = False) -> str:
now = self._now()
if (
not force_refresh
and self._access_token is not None
and self._access_token.valid(now, self._config.token_refresh_skew_seconds)
):
return self._access_token.token
data = self._request_json(
"GET",
"/cgi-bin/gettoken",
query={
"corpid": self._config.corp_id,
"corpsecret": self._config.corp_secret,
},
)
token = str(data.get("access_token", ""))
if not token:
raise WeComAPIError(
"企业微信接口未返回 access_token",
http_status=200,
endpoint="/cgi-bin/gettoken",
)
expires_in = float(data.get("expires_in", 7200))
self._access_token = AccessToken(token=token, expires_at=now + expires_in)
return token
def send_text(
self,
*,
content: str,
to_user: Optional[str] = None,
to_party: Optional[str] = None,
to_tag: Optional[str] = None,
safe: int = 0,
enable_duplicate_check: int = 0,
duplicate_check_interval: int = 1800,
) -> dict[str, Any]:
token = self._get_access_token()
payload: dict[str, Any] = {
"msgtype": "text",
"agentid": self._config.agent_id,
"text": {"content": content},
"safe": safe,
"enable_duplicate_check": enable_duplicate_check,
"duplicate_check_interval": duplicate_check_interval,
}
if to_user:
payload["touser"] = to_user
if to_party:
payload["toparty"] = to_party
if to_tag:
payload["totag"] = to_tag
if not any((to_user, to_party, to_tag)):
payload["touser"] = "@all"
return self._request_json(
"POST",
"/cgi-bin/message/send",
query={"access_token": token},
payload=payload,
)
__all__ = [
"DEFAULT_API_BASE",
"DEFAULT_TIMEOUT_SECONDS",
"DEFAULT_TOKEN_REFRESH_SKEW_SECONDS",
"WeComAPIError",
"WeComAppClient",
"WeComAppConfig",
"WeComBotClient",
"WeComBotConfig",
"WeComConfigError",
]