308 lines
8.6 KiB
Python
308 lines
8.6 KiB
Python
"""
|
|
WeChat iLink protocol for sending text messages and polling updates.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
from .locale import _
|
|
|
|
|
|
# Default WeChat iLink gateway URL
|
|
DEFAULT_BASE_URL: str = "https://ilinkai.weixin.qq.com"
|
|
|
|
# Required value of base_info.channel_version in every iLink request body
|
|
_CHANNEL_VERSION: str = "2.2.0"
|
|
|
|
# iLink client identifier sent in the iLink-App-Id request header
|
|
_APP_ID: str = "bot"
|
|
|
|
# iLink client version; Major.Minor.Patch=2.2.0 encoded as 24-bit integer (131584)
|
|
_CLIENT_VERSION: str = str((2 << 16) | (2 << 8) | 0)
|
|
|
|
# Request timeout
|
|
_REQUEST_TIMEOUT: float = 30.0
|
|
|
|
|
|
class WeixinError(Exception):
|
|
"""SDK general exception."""
|
|
|
|
|
|
class SessionExpired(WeixinError):
|
|
"""iLink reports session expired (errcode/ret == -14)."""
|
|
|
|
|
|
class WeixinTimeout(WeixinError):
|
|
"""HTTP request timed out."""
|
|
|
|
|
|
@dataclass
|
|
class Credentials:
|
|
"""
|
|
Access credentials returned by iLink QR-code login.
|
|
"""
|
|
|
|
bot_id: str
|
|
token: str
|
|
base_url: str = DEFAULT_BASE_URL
|
|
|
|
|
|
def send_text(
|
|
creds: Credentials,
|
|
user_id: str,
|
|
text: str,
|
|
) -> None:
|
|
"""
|
|
Send a text message to the specified user.
|
|
"""
|
|
msg: dict[str, Any] = {
|
|
"from_user_id": "",
|
|
"to_user_id": user_id,
|
|
"client_id": uuid.uuid4().hex,
|
|
"message_type": 2, # 2 = bot active message
|
|
"message_state": 2, # 2 = sent
|
|
"item_list": [
|
|
{"type": 1, "text_item": {"text": text}}, # type=1 = text item
|
|
],
|
|
}
|
|
|
|
with requests.Session() as session:
|
|
_post(
|
|
creds.base_url,
|
|
"ilink/bot/sendmessage",
|
|
creds.token,
|
|
{"msg": msg},
|
|
session=session,
|
|
timeout=_REQUEST_TIMEOUT,
|
|
)
|
|
|
|
|
|
def poll(
|
|
creds: Credentials,
|
|
sync_buf: str = "",
|
|
) -> tuple[list[str], str]:
|
|
"""
|
|
Long-poll inbound messages from iLink.
|
|
|
|
Returns a tuple of (user IDs, next sync_buf).
|
|
"""
|
|
with requests.Session() as session:
|
|
data: dict[str, Any] = _post(
|
|
creds.base_url,
|
|
"ilink/bot/getupdates",
|
|
creds.token,
|
|
{"get_updates_buf": sync_buf},
|
|
session=session,
|
|
timeout=60,
|
|
)
|
|
|
|
next_buf: str = str(data.get("get_updates_buf") or sync_buf)
|
|
user_ids: list[str] = []
|
|
|
|
raw_messages: Any = data.get("msgs")
|
|
if isinstance(raw_messages, list):
|
|
for raw in raw_messages:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
|
|
user_id: str = _parse_user_id(raw, creds.bot_id)
|
|
if user_id:
|
|
user_ids.append(user_id)
|
|
|
|
return user_ids, next_buf
|
|
|
|
|
|
def request_qrcode() -> tuple[str, str]:
|
|
"""
|
|
Request a new QR code from iLink.
|
|
|
|
Returns a tuple of (qrcode, scan_url).
|
|
"""
|
|
with requests.Session() as session:
|
|
qr_data: dict[str, Any] = _get(
|
|
DEFAULT_BASE_URL,
|
|
"ilink/bot/get_bot_qrcode",
|
|
{"bot_type": "3"},
|
|
session=session,
|
|
timeout=_REQUEST_TIMEOUT,
|
|
)
|
|
|
|
qrcode: str = str(qr_data.get("qrcode") or "").strip()
|
|
if not qrcode:
|
|
raise WeixinError(_("二维码响应缺少 qrcode"))
|
|
|
|
scan_url: str = str(qr_data.get("qrcode_img_content") or "").strip() or qrcode
|
|
return qrcode, scan_url
|
|
|
|
|
|
def wait_for_login(
|
|
qrcode: str,
|
|
deadline: float,
|
|
) -> Credentials | None:
|
|
"""
|
|
Poll QR-code status until confirmed, expired or deadline reached.
|
|
|
|
Returns Credentials on confirmation, or None if expired or deadline reached.
|
|
"""
|
|
current_url: str = DEFAULT_BASE_URL
|
|
|
|
with requests.Session() as session:
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
status_data: dict[str, Any] = _get(
|
|
current_url,
|
|
"ilink/bot/get_qrcode_status",
|
|
{"qrcode": qrcode},
|
|
session=session,
|
|
timeout=_REQUEST_TIMEOUT,
|
|
)
|
|
except WeixinTimeout:
|
|
continue
|
|
status: str = str(status_data.get("status") or "").strip()
|
|
|
|
if status == "confirmed":
|
|
creds: Credentials = Credentials(
|
|
bot_id=str(status_data.get("ilink_bot_id") or "").strip(),
|
|
token=str(status_data.get("bot_token") or "").strip(),
|
|
base_url=str(status_data.get("baseurl") or current_url),
|
|
)
|
|
if not creds.bot_id or not creds.token:
|
|
raise WeixinError(_("扫码确认响应缺少凭据"))
|
|
return creds
|
|
|
|
if status == "scaned_but_redirect":
|
|
# iLink requires switching follow-up requests to redirect_host
|
|
host: str = str(status_data.get("redirect_host") or "").strip()
|
|
if host:
|
|
current_url = f"https://{host}"
|
|
elif status == "expired":
|
|
return None
|
|
|
|
time.sleep(1)
|
|
|
|
return None
|
|
|
|
|
|
def _post(
|
|
base_url: str,
|
|
path: str,
|
|
token: str | None,
|
|
payload: dict[str, Any],
|
|
session: requests.Session,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
"""Send an iLink POST request and validate the business return code."""
|
|
body: bytes = json.dumps(
|
|
{**payload, "base_info": {"channel_version": _CHANNEL_VERSION}},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
url: str = f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
headers: dict[str, str] = _headers(token)
|
|
|
|
try:
|
|
response: requests.Response = session.post(
|
|
url,
|
|
data=body,
|
|
headers=headers,
|
|
timeout=timeout,
|
|
)
|
|
except requests.exceptions.Timeout as exc:
|
|
raise WeixinTimeout(_("HTTP 请求超时: {}").format(exc)) from exc
|
|
except requests.RequestException as exc:
|
|
raise WeixinError(_("HTTP 请求失败: {}").format(exc)) from exc
|
|
|
|
return _parse(response)
|
|
|
|
|
|
def _get(
|
|
base_url: str,
|
|
path: str,
|
|
params: dict[str, Any],
|
|
session: requests.Session,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
"""Send an iLink GET request and validate the business return code."""
|
|
url: str = f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
headers: dict[str, str] = {
|
|
"iLink-App-Id": _APP_ID,
|
|
"iLink-App-ClientVersion": _CLIENT_VERSION,
|
|
}
|
|
|
|
try:
|
|
response: requests.Response = session.get(
|
|
url,
|
|
params=params,
|
|
headers=headers,
|
|
timeout=timeout,
|
|
)
|
|
except requests.exceptions.Timeout as exc:
|
|
raise WeixinTimeout(_("HTTP 请求超时: {}").format(exc)) from exc
|
|
except requests.RequestException as exc:
|
|
raise WeixinError(_("HTTP 请求失败: {}").format(exc)) from exc
|
|
|
|
return _parse(response)
|
|
|
|
|
|
def _parse(response: requests.Response) -> dict[str, Any]:
|
|
"""Parse iLink response, validate HTTP status and business return code."""
|
|
if not response.ok:
|
|
raise WeixinError(f"HTTP {response.status_code}: {response.text[:200]}")
|
|
|
|
try:
|
|
data: Any = response.json()
|
|
except ValueError as exc:
|
|
raise WeixinError(_("非 JSON 响应: {}").format(response.text[:200])) from exc
|
|
|
|
if not isinstance(data, dict):
|
|
raise WeixinError(_("响应不是 JSON 对象: {}").format(response.text[:200]))
|
|
|
|
ret: Any = data.get("ret") or 0
|
|
err: Any = data.get("errcode") or 0
|
|
|
|
# -14 is the iLink error code for expired session.
|
|
if ret == -14 or err == -14:
|
|
raise SessionExpired(str(data.get("errmsg") or _("iLink 会话已过期")))
|
|
if ret or err:
|
|
raise WeixinError(
|
|
_("iLink 错误 ret={} errcode={} errmsg={}").format(
|
|
ret,
|
|
err,
|
|
data.get("errmsg"),
|
|
),
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def _headers(token: str | None) -> dict[str, str]:
|
|
"""Generate standard headers for an iLink POST request."""
|
|
# X-WECHAT-UIN: regenerated per request, random uint32 -> decimal -> base64
|
|
uin: str = base64.b64encode(str(secrets.randbelow(1 << 32)).encode()).decode()
|
|
headers: dict[str, str] = {
|
|
"Content-Type": "application/json",
|
|
"AuthorizationType": "ilink_bot_token",
|
|
"X-WECHAT-UIN": uin,
|
|
"iLink-App-Id": _APP_ID,
|
|
"iLink-App-ClientVersion": _CLIENT_VERSION,
|
|
}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def _parse_user_id(raw: dict[str, Any], bot_id: str) -> str:
|
|
"""Parse sender user ID from an iLink raw message; ignore bot's own messages."""
|
|
sender: str = str(raw.get("from_user_id") or "").strip()
|
|
if not sender or sender == bot_id:
|
|
return ""
|
|
|
|
return sender
|