Files
abot/wechat_ipad/providers/server_864/login.py

259 lines
13 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.
import qrcode
from urllib.parse import parse_qs, urlparse
from wechat_ipad.providers.server_864.base import Server864APIClientBase
class LoginMixin(Server864APIClientBase):
"""864 登录相关接口。"""
@staticmethod
def _normalize_online_text(value) -> str:
"""把 864 各种登录态字段压平成便于判断的字符串。"""
return str(value or "").strip().lower()
def _is_online_from_login_status_payload(self, data: dict | None) -> bool:
"""根据 `GetLoginStatus` 的返回结构判断当前是否在线。
设计说明:
1. 864 不同版本对在线态字段命名并不统一,可能是 `loginState/status/state/isLogin` 中的任意一种;
2. 这里集中做一次宽松识别,避免上层 runtime 到处散落同类判断;
3. 后续如果 864 新版本再补字段,只需要在这里扩展,不必改多处业务逻辑。
"""
payload = dict(data or {})
normalized_login_state = self._normalize_online_text(
self._pick_first(payload, "loginState", "LoginState", "status_text", "statusText")
)
raw_login_state = self._pick_first(payload, "loginState", "LoginState")
normalized_status = self._normalize_online_text(
self._pick_first(payload, "status", "Status", "state_text", "stateText")
)
state_value = self._pick_first(payload, "state", "State")
login_flag = self._pick_first(payload, "isLogin", "IsLogin", "online", "Online", "isOnline", "IsOnline")
login_err_msg = self._normalize_online_text(
self._pick_first(payload, "loginErrMsg", "LoginErrMsg", "msg", "message")
)
if normalized_login_state in {"online", "已登录", "在线"}:
return True
# 864 的 `GetLoginStatus` 在你当前这版 server 里会直接返回 `loginState: 1`
# 1. 这不是扫码阶段的 `CheckLoginStatus.state`,而是服务端自身维护的登录态枚举;
# 2. 之前这里只识别到了字符串 `"online"`,导致明明已经在线却仍被判成未登录;
# 3. 这里把常见数字态一并纳入在线判定,避免后续资料拉取与前端显示被卡住。
try:
normalized_login_state_value = int(raw_login_state or 0)
except (TypeError, ValueError):
normalized_login_state_value = 0
if normalized_login_state_value in {1, 2}:
return True
if normalized_status in {"online", "已登录", "在线"}:
return True
if "在线状态良好" in login_err_msg or "账号在线" in login_err_msg:
return True
if isinstance(login_flag, bool):
return login_flag
if str(login_flag or "").strip().lower() in {"true", "1", "online"}:
return True
try:
normalized_state_value = int(state_value or 0)
except (TypeError, ValueError):
normalized_state_value = 0
return normalized_state_value in {1, 2}
def _extract_login_identity_from_status(self, data: dict | None) -> dict:
"""从 `GetLoginStatus` 返回中提取尽可能多的账号身份字段。"""
payload = dict(data or {})
return {
"wxid": str(self._pick_first(payload, "wxid", "Wxid", "UserName", "userName") or "").strip(),
"nickname": str(self._pick_first(payload, "nick_name", "nickName", "NickName", "nickname") or "").strip(),
"alias": str(self._pick_first(payload, "alias", "Alias", "wechatId", "WeChatId") or "").strip(),
"phone": str(self._pick_first(payload, "mobile", "Mobile", "phone", "Phone") or "").strip(),
"signature": str(self._pick_first(payload, "signature", "Signature") or "").strip(),
}
@staticmethod
def _normalize_login_way(login_way: str) -> str:
"""标准化 864 `GetLoginQrCodeNewX` 的 way 参数。"""
normalized_way = str(login_way or "").strip().lower()
return normalized_way if normalized_way in {"mac", "win", "harmony", "car", "watch"} else "mac"
def _extract_qr_response(self, data) -> tuple[str, str]:
"""从 864 的二维码返回结构中提取 uuid 与二维码地址。"""
qr_code_url = (
self._pick_first(data, "QrCodeUrl", "QRCodeUrl", "qrCodeUrl")
or self._pick_first(data, "QrUrl", "QRUrl", "qrUrl")
or self._pick_first(self._pick_first(data, "Qrcode", "QrCode", "qrcode") or {}, "Src", "src")
or ""
)
uuid = self._pick_first(data, "UUID", "Uuid", "uuid") or ""
if not uuid and qr_code_url:
# 864 的真实返回经常只给“二维码图片地址”,并不会直接带出 uuid
# 1. 图床链接 query 中的 `data=http://weixin.qq.com/x/<uuid>` 才是真正扫码值;
# 2. Dashboard 当前以 uuid 作为刷新与历史记录主键,缺失后前端体验会明显变差;
# 3. 因此这里统一做一次反解,让 New / NewX 两种接口都复用同一份展示逻辑。
parsed_qs = parse_qs(urlparse(str(qr_code_url)).query)
scan_data = str((parsed_qs.get("data") or [""])[0] or "")
if "/x/" in scan_data:
uuid = scan_data.rsplit("/x/", 1)[-1].strip()
return str(uuid), str(qr_code_url)
async def get_qr_code(
self,
device_name: str = "",
device_id: str = "",
proxy=None,
print_qr: bool = False,
login_qr_api: str = "new_x",
login_way: str = "mac",
) -> tuple[str, str]:
"""获取 864 登录二维码。
说明:
1. 864 不依赖 855 的 `device_name/device_id` 入参,但保留参数签名以兼容上层调用;
2. `proxy` 当前仅保留兼容占位,后续如需补实际代理登录,可直接映射到 swagger 的 Proxy 字段;
3. 返回值继续保持 `(uuid, url)`,方便 Dashboard 与运行时共用同一套二维码展示逻辑。
"""
del device_name, device_id
proxy_value = ""
if proxy is not None:
proxy_value = getattr(proxy, "proxy", "") or ""
normalized_login_qr_api = str(login_qr_api or "new_x").strip().lower()
normalized_login_way = self._normalize_login_way(login_way)
if normalized_login_qr_api in {"harmony_api", "harmony", "harmony_login_api"}:
# `HarmonyLoginApi` 是 864 服务端单独暴露的一条鸿蒙二维码链路:
# 1. 用户当前希望在 Dashboard 上显式切换“鸿蒙专用二维码”和“标准 New 二维码”;
# 2. 该接口的请求模型与 `GetLoginQrCodeNew` 一致,仍然只需要 `Proxy/Check`
# 3. 因此这里单独加一个模式键,不去复用 `new_x + way=harmony`,避免两条链路在运维上混淆。
data = await self._request_data(
"post",
"/login/HarmonyLoginApi",
json_body={"Proxy": proxy_value, "Check": False},
timeout=30,
)
uuid, qr_url = self._extract_qr_response(data)
elif normalized_login_qr_api in {"new_x", "x", "newx"}:
try:
# NewX 是当前 864 联调里更完整的一条登录链路:
# 1. 它支持 `Way` 指定登录端形态,兼容更多 server 变体;
# 2. 用户实测也验证该接口可正常返回二维码;
# 3. 若某些旧版 864 仍未提供 NewX则自动回退到旧接口避免新配置直接打挂启动。
data = await self._request_data(
"post",
"/login/GetLoginQrCodeNewX",
json_body={"Proxy": proxy_value, "Check": False, "Way": normalized_login_way},
timeout=30,
)
uuid, qr_url = self._extract_qr_response(data)
except Exception:
data = await self._request_data(
"post",
"/login/GetLoginQrCodeNew",
json_body={"Proxy": proxy_value, "Check": False},
timeout=30,
)
uuid, qr_url = self._extract_qr_response(data)
elif normalized_login_qr_api in {"new", "legacy_new"}:
data = await self._request_data(
"post",
"/login/GetLoginQrCodeNew",
json_body={"Proxy": proxy_value, "Check": False},
timeout=30,
)
uuid, qr_url = self._extract_qr_response(data)
else:
data = await self._request_data(
"post",
"/login/GetLoginQrCodeNew",
json_body={"Proxy": proxy_value, "Check": False},
timeout=30,
)
uuid, qr_url = self._extract_qr_response(data)
if print_qr and uuid:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(f"http://weixin.qq.com/x/{uuid}")
qr.make(fit=True)
qr.print_ascii()
return str(uuid), str(qr_url)
async def check_login_status(self) -> tuple[bool, dict]:
"""检查当前二维码登录状态。"""
data = await self._request_data("get", "/login/CheckLoginStatus", timeout=20)
normalized = dict(data or {})
# 864 扫码后可能不会直接变成 online而是先返回一个安全验证链接
# 1. 该字段在 swagger / 实机联调里都存在,但大小写并不稳定;
# 2. 上层 runtime 只关心统一字段名,因此这里先做一层归一化;
# 3. 这样 Dashboard 只需要消费 `verification_url`,不用感知各 server 的原始差异。
verification_url = self._pick_first(
normalized,
"VerificationUrl",
"VerificationURL",
"verificationUrl",
"verification_url",
)
if verification_url:
normalized["verification_url"] = str(verification_url).strip()
# 864 的扫码状态字段在不同版本里语义并不完全一致:
# 1. 上层运行时需要知道原始 state才能区分“等待扫码 / 安全验证 / 终态缓存未清理”等场景;
# 2. 因此这里除了保留旧的 `state` 字段外,再显式放一份 `raw_state` 供 runtime 做分支;
# 3. 同时把昵称、头像这些已扫描用户信息一并透出,便于 Dashboard 展示最新状态。
state = int(normalized.get("state", 0) or 0)
normalized["raw_state"] = state
if self._pick_first(normalized, "nick_name", "nickName", "NickName"):
normalized["nick_name"] = str(
self._pick_first(normalized, "nick_name", "nickName", "NickName") or ""
).strip()
if self._pick_first(normalized, "head_img_url", "headImgUrl", "HeadImgUrl"):
normalized["head_img_url"] = str(
self._pick_first(normalized, "head_img_url", "headImgUrl", "HeadImgUrl") or ""
).strip()
login_state = str(normalized.get("loginState", "") or "").strip().lower()
return state == 2 or login_state == "online", normalized
async def get_init_status(self) -> bool:
"""检查 server 侧初始化是否完成。"""
data = await self._request_data("get", "/login/GetInItStatus", timeout=15)
return bool(data)
async def awaken_login(self, wxid: str = "") -> dict:
"""触发 864 的唤醒登录。"""
del wxid
return await self._request_data("post", "/login/WakeUpLogin", timeout=30)
async def get_login_status(self, auto_login: bool = True) -> dict:
"""获取 864 在线状态。"""
data = await self._request_data(
"get",
"/login/GetLoginStatus",
params={"autoLogin": str(bool(auto_login)).lower()},
timeout=20,
)
normalized = dict(data or {})
# 把最常用的在线态判断和身份字段提前归一化:
# 1. 这样上层只要消费 `is_online` / `wxid` / `nickname` 这些统一键,不必感知原始 swagger 字段差异;
# 2. 与 `CheckLoginStatus` 一样Dashboard 和 runtime 都能共享同一份兼容结果;
# 3. 也方便后续把 864 的不同 server 版本收敛到更薄的一层 provider 适配。
normalized["is_online"] = self._is_online_from_login_status_payload(normalized)
normalized.update(self._extract_login_identity_from_status(normalized))
return normalized
async def log_out(self) -> bool:
"""退出当前 864 登录态。"""
try:
# 不同 864 版本里退出接口命名存在差异:
# 1. 当前项目早期接的是 `/login/LogOutRequest`
# 2. 用户本地 864 源码中实际注册的是 `/login/LogOut`
# 3. 因此这里优先尝试较新的 `/LogOut`,失败后再回退到旧路径,降低版本切换成本。
await self._request_data("get", "/login/LogOut", timeout=15)
except Exception:
await self._request_data("get", "/login/LogOutRequest", timeout=15)
return True