From cf6b676a563d3433f00d5b38be92e682087e8763 Mon Sep 17 00:00:00 2001 From: liuwei Date: Thu, 7 May 2026 14:09:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=8D=A2864=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=88=B0NewX=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=AE=89=E5=85=A8=E9=AA=8C=E8=AF=81=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.example | 2 + .env.example | 2 + Dockerfile | 2 + README.MD | 8 ++ admin/dashboard/templates/index.html | 20 +++++ config.example.yaml | 4 + config.yaml | 4 + configuration.py | 6 ++ docker-compose.yml | 2 + docker-entrypoint.sh | 2 + robot.py | 2 + wechat_ipad/providers/server_864/login.py | 97 ++++++++++++++++----- wechat_ipad/providers/server_864/runtime.py | 33 ++++++- 13 files changed, 156 insertions(+), 28 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 7354ea9..83e424d 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -23,6 +23,8 @@ WECHAT_SERVER_PORT=8059 WECHAT_SERVER_TYPE=legacy_855 # 当 WECHAT_SERVER_TYPE=server_864 时必须提供。 WECHAT_SERVER_KEY= +WECHAT_LOGIN_QR_API=new_x +WECHAT_LOGIN_WAY=mac WECHAT_WXID= WECHAT_DEVICE_NAME= WECHAT_DEVICE_ID= diff --git a/.env.example b/.env.example index 4a03fa4..d08971c 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,8 @@ WECHAT_SERVER_PORT=8059 WECHAT_SERVER_TYPE=legacy_855 # 当使用 server_864 时,这里必须填写服务端分配的固定 key。 WECHAT_SERVER_KEY= +WECHAT_LOGIN_QR_API=new_x +WECHAT_LOGIN_WAY=mac # 以下三项可留空,首次登录后会自动写入本地状态缓存文件。 WECHAT_WXID= WECHAT_DEVICE_NAME= diff --git a/Dockerfile b/Dockerfile index 6da71f1..45593b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,8 @@ ENV ABOT_ENVIRONMENT=production \ WECHAT_SERVER_IP=127.0.0.1 \ WECHAT_SERVER_PORT=8059 \ WECHAT_SERVER_KEY= \ + WECHAT_LOGIN_QR_API=new_x \ + WECHAT_LOGIN_WAY=mac \ WECHAT_WXID= \ WECHAT_DEVICE_NAME=ABOTPad \ WECHAT_DEVICE_ID= \ diff --git a/README.MD b/README.MD index 21d4750..ada81a8 100644 --- a/README.MD +++ b/README.MD @@ -115,6 +115,8 @@ python main.py - `WECHAT_SERVER_PORT` - `WECHAT_SERVER_TYPE` - `WECHAT_SERVER_KEY`:仅 `server_864` 必填 +- `WECHAT_LOGIN_QR_API`:`server_864` 登录二维码接口选择,默认 `new_x` +- `WECHAT_LOGIN_WAY`:`server_864` 使用 `GetLoginQrCodeNewX` 时的终端形态,默认 `mac` 运行时状态不再要求人工维护,会自动写到: @@ -126,6 +128,12 @@ python main.py - 切换 `855 / 864` 不会默认串用同一份状态 - 开源仓库不需要再长期维护多份分散配置文件 +### 864 登录补充说明 + +`server_864` 现默认优先调用 `POST /login/GetLoginQrCodeNewX` 获取二维码;若目标 server 不支持,再自动回退到旧的 `GetLoginQrCodeNew`。 + +部分 864 版本在扫码后不会立刻登录成功,而是会在 `CheckLoginStatus` 中返回 `VerificationUrl`。当前 Dashboard 已支持直接展示并打开这个链接,方便你在新环境完成微信安全验证。 + ## Docker 交付说明 当前仓库已经提供以下开源友好交付物: diff --git a/admin/dashboard/templates/index.html b/admin/dashboard/templates/index.html index 6bb8987..773317b 100644 --- a/admin/dashboard/templates/index.html +++ b/admin/dashboard/templates/index.html @@ -169,6 +169,9 @@ 复制扫码链接 + + 打开安全验证链接 + @@ -658,6 +661,9 @@ if (this.loginQrDialog.provider_stage === 'login_required') { return '需要重新登录'; } + if (this.loginQrDialog.provider_stage === 'verification_required') { + return '等待安全验证'; + } return toneMap[this.loginQrDialog.status] || '等待登录流程'; }, loginQrSourceText() { @@ -666,6 +672,9 @@ if (this.loginQrDialog.provider_stage === 'connection_pending') { return '864 服务端准备中'; } + if (this.loginQrDialog.provider_stage === 'verification_required') { + return '864 二次验证'; + } return '864 服务端登录'; } const source = this.loginQrCurrent.login_source; @@ -687,6 +696,9 @@ if (this.loginQrDialog.provider_stage === 'connection_pending') { return '等待服务端准备'; } + if (this.loginQrDialog.provider_stage === 'verification_required') { + return '等待打开验证链接'; + } if (this.loginQrDialog.provider_stage === 'login_required' && !this.loginQrCurrent.uuid) { return '等待新二维码'; } @@ -935,6 +947,14 @@ } this.fallbackCopyLoginQrScanUrl(scanUrl); }, + openLoginQrVerificationUrl() { + const verificationUrl = this.loginQrCurrent.verification_url || ''; + if (!verificationUrl) { + this.$message.warning('当前暂无可打开的验证链接'); + return; + } + window.open(verificationUrl, '_blank', 'noopener'); + }, fallbackCopyLoginQrScanUrl(scanUrl) { const textarea = document.createElement('textarea'); textarea.value = scanUrl; diff --git a/config.example.yaml b/config.example.yaml index 775990d..ef94780 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -55,6 +55,10 @@ wechat_ipad: server_type: "${WECHAT_SERVER_TYPE:legacy_855}" # 当 server_type=server_864 时必须提供固定 key;855/859 可留空。 server_key: "${WECHAT_SERVER_KEY:}" + # 864 登录二维码接口:默认优先尝试 new_x,再按 provider 内部逻辑回退。 + login_qr_api: "${WECHAT_LOGIN_QR_API:new_x}" + # 当使用 GetLoginQrCodeNewX 时,常用值可为 mac / win / harmony / car / watch。 + login_way: "${WECHAT_LOGIN_WAY:mac}" wxid: "${WECHAT_WXID:}" device_name: "${WECHAT_DEVICE_NAME:}" device_id: "${WECHAT_DEVICE_ID:}" diff --git a/config.yaml b/config.yaml index 775990d..ef94780 100644 --- a/config.yaml +++ b/config.yaml @@ -55,6 +55,10 @@ wechat_ipad: server_type: "${WECHAT_SERVER_TYPE:legacy_855}" # 当 server_type=server_864 时必须提供固定 key;855/859 可留空。 server_key: "${WECHAT_SERVER_KEY:}" + # 864 登录二维码接口:默认优先尝试 new_x,再按 provider 内部逻辑回退。 + login_qr_api: "${WECHAT_LOGIN_QR_API:new_x}" + # 当使用 GetLoginQrCodeNewX 时,常用值可为 mac / win / harmony / car / watch。 + login_way: "${WECHAT_LOGIN_WAY:mac}" wxid: "${WECHAT_WXID:}" device_name: "${WECHAT_DEVICE_NAME:}" device_id: "${WECHAT_DEVICE_ID:}" diff --git a/configuration.py b/configuration.py index bd1c9ed..8697112 100644 --- a/configuration.py +++ b/configuration.py @@ -219,6 +219,12 @@ class Config(object): # 2. 因此这里把 `server_key` 也纳入统一配置归一化,确保 `.env` 成为唯一静态维护入口; # 3. 留空仍允许通过校验阶段给出明确提示,而不是在 provider 启动后才报模糊错误。 wechat_ipad_config["server_key"] = str(wechat_ipad_config.get("server_key", "") or "").strip() + wechat_ipad_config["login_qr_api"] = str( + wechat_ipad_config.get("login_qr_api", "new_x") or "new_x" + ).strip() + wechat_ipad_config["login_way"] = str( + wechat_ipad_config.get("login_way", "mac") or "mac" + ).strip() wechat_ipad_config["wxid"] = str(wechat_ipad_config.get("wxid", "") or "").strip() wechat_ipad_config["device_name"] = str(wechat_ipad_config.get("device_name", "") or "").strip() wechat_ipad_config["device_id"] = str(wechat_ipad_config.get("device_id", "") or "").strip() diff --git a/docker-compose.yml b/docker-compose.yml index 9ae4550..dde4537 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,8 @@ services: WECHAT_SERVER_PORT: ${WECHAT_SERVER_PORT:-8059} WECHAT_SERVER_TYPE: ${WECHAT_SERVER_TYPE:-legacy_855} WECHAT_SERVER_KEY: ${WECHAT_SERVER_KEY:-} + WECHAT_LOGIN_QR_API: ${WECHAT_LOGIN_QR_API:-new_x} + WECHAT_LOGIN_WAY: ${WECHAT_LOGIN_WAY:-mac} WECHAT_WXID: ${WECHAT_WXID:-} WECHAT_DEVICE_NAME: ${WECHAT_DEVICE_NAME:-} WECHAT_DEVICE_ID: ${WECHAT_DEVICE_ID:-} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index d495c9d..cb23389 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -57,6 +57,8 @@ wechat_ipad: # 2. 855/859 保持可留空,不影响现有默认行为; # 3. 真正的值仍由 `.env` / compose 环境变量注入,不会写死在镜像层。 server_key: "\${WECHAT_SERVER_KEY:}" + login_qr_api: "\${WECHAT_LOGIN_QR_API:new_x}" + login_way: "\${WECHAT_LOGIN_WAY:mac}" wxid: "\${WECHAT_WXID:}" device_name: "\${WECHAT_DEVICE_NAME:}" device_id: "\${WECHAT_DEVICE_ID:}" diff --git a/robot.py b/robot.py index 6c6304f..6d78332 100644 --- a/robot.py +++ b/robot.py @@ -542,12 +542,14 @@ class Robot: provider_stage = str((payload or {}).get("provider_stage", "waiting_scan") or "waiting_scan").strip() connection_ready = bool((payload or {}).get("connection_ready", False)) login_required = bool((payload or {}).get("login_required", True)) + verification_url = str((payload or {}).get("verification_url", "") or "").strip() expires_in = (payload or {}).get("expires_in") expires_in = None if expires_in in (None, "") else max(0, int(expires_in)) current_record = { "uuid": uuid_value, "scan_url": scan_url, "raw_url": raw_url, + "verification_url": verification_url, "image_data": self._build_qr_image_data(scan_url), "status": status, "status_text": status_text, diff --git a/wechat_ipad/providers/server_864/login.py b/wechat_ipad/providers/server_864/login.py index c30da17..2b7d74c 100644 --- a/wechat_ipad/providers/server_864/login.py +++ b/wechat_ipad/providers/server_864/login.py @@ -7,12 +7,40 @@ from wechat_ipad.providers.server_864.base import Server864APIClientBase class LoginMixin(Server864APIClientBase): """864 登录相关接口。""" + @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/` 才是真正扫码值; + # 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 登录二维码。 @@ -26,30 +54,38 @@ class LoginMixin(Server864APIClientBase): if proxy is not None: proxy_value = getattr(proxy, "proxy", "") or "" - data = await self._request_data( - "post", - "/login/GetLoginQrCodeNew", - json_body={"Proxy": proxy_value, "Check": False}, - timeout=30, - ) - 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 当前真实返回的是“二维码图片服务地址”, - # 其中真正的扫码链接藏在 `data=http://weixin.qq.com/x/` 这个 query 里: - # 1. Dashboard 需要 uuid 才能稳定生成扫码地址与展示文案; - # 2. 因此这里把 query 中的真实扫码链接反解出来,兼容当前 server 的返回格式; - # 3. 若未来某些 864 版本直接返回 UUID,本逻辑仍会优先使用原始字段,不会互相冲突。 - 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() - qr_url = str(qr_code_url) + 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 {"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) + 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( @@ -68,6 +104,19 @@ class LoginMixin(Server864APIClientBase): """检查当前二维码登录状态。""" 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() state = int(normalized.get("state", 0) or 0) login_state = str(normalized.get("loginState", "") or "").strip().lower() return state == 2 or login_state == "online", normalized diff --git a/wechat_ipad/providers/server_864/runtime.py b/wechat_ipad/providers/server_864/runtime.py index 2c644c9..7fd1fb0 100644 --- a/wechat_ipad/providers/server_864/runtime.py +++ b/wechat_ipad/providers/server_864/runtime.py @@ -48,6 +48,8 @@ class Server864RuntimeMixin: if not server_key: raise ValueError("server_864 启动失败:缺少 server_key,请在 .env 中配置 WECHAT_SERVER_KEY") self.server_key = server_key + login_qr_api = str(ipad_config.get("login_qr_api", "new_x") or "new_x").strip() + login_way = str(ipad_config.get("login_way", "mac") or "mac").strip() await self._ensure_login( ipad_config=ipad_config, @@ -55,6 +57,8 @@ class Server864RuntimeMixin: logger=logger, on_login_qr_update=on_login_qr_update, on_login_qr_cleared=on_login_qr_cleared, + login_qr_api=login_qr_api, + login_way=login_way, ) await on_login_ready(self.get_login_identity()) @@ -97,6 +101,8 @@ class Server864RuntimeMixin: logger, on_login_qr_update: AsyncCallback | None = None, on_login_qr_cleared: AsyncCallback | None = None, + login_qr_api: str = "new_x", + login_way: str = "mac", ) -> None: """确保 864 已完成登录。""" if await self.is_logged_in(): @@ -128,7 +134,7 @@ class Server864RuntimeMixin: callback_name="on_login_qr_update", ) - uuid, url = await self.get_qr_code(print_qr=True) + uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way) scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" await self._safe_callback( on_login_qr_update, @@ -144,6 +150,8 @@ class Server864RuntimeMixin: "provider_stage": "waiting_scan", "connection_ready": False, "login_required": True, + "login_qr_api": login_qr_api, + "login_way": login_way, }, logger=logger, callback_name="on_login_qr_update", @@ -174,11 +182,23 @@ class Server864RuntimeMixin: # 3. 一旦 server 侧切换了新的 uuid,这里也要及时覆盖本地展示态,避免前端一直盯着旧码。 latest_uuid = str(login_status.get("uuid", "") or uuid).strip() or uuid effective_time = int(login_status.get("effective_time", 0) or 0) + verification_url = str(login_status.get("verification_url", "") or "").strip() if latest_uuid != uuid: uuid = latest_uuid scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" url = f"https://api.2dcode.biz/v1/create-qr-code?data={scan_url}" if scan_url else url + provider_stage = "verification_required" if verification_url else ("waiting_scan" if uuid else "login_required") + # 864 在“已扫码但待安全验证”阶段,`msg/loginState` 往往是空字符串: + # 1. 若直接套用默认“等待扫码登录”,用户会误以为还没扫上; + # 2. 因此这里优先识别 `verification_url`,给 Dashboard 一个更准确的引导文案; + # 3. 只有完全拿不到状态提示时,才回退到普通扫码等待文案。 + raw_status_text = str(login_status.get("msg") or login_status.get("loginState") or "").strip() + if verification_url and not raw_status_text: + status_text = "扫码已完成,请继续打开验证链接完成安全验证" + else: + status_text = raw_status_text or "等待扫码登录" + await self._safe_callback( on_login_qr_update, { @@ -187,12 +207,15 @@ class Server864RuntimeMixin: "scan_url": scan_url, "expires_in": effective_time if effective_time > 0 else None, "status": "waiting", - "status_text": str(login_status.get("msg") or login_status.get("loginState") or "等待扫码登录"), + "status_text": status_text, "login_source": "fresh_qr", "provider_name": "server_864", - "provider_stage": "waiting_scan" if uuid else "login_required", + "provider_stage": provider_stage, "connection_ready": False, "login_required": True, + "verification_url": verification_url, + "login_qr_api": login_qr_api, + "login_way": login_way, }, logger=logger, callback_name="on_login_qr_update", @@ -203,7 +226,7 @@ class Server864RuntimeMixin: # 2. 也能让新环境登录时的交互与 855 保持一致,都是“过期就自动刷新”; # 3. 重新申请后直接回到当前 while 顶部继续轮询新的 uuid 状态。 if effective_time <= 0: - uuid, url = await self.get_qr_code(print_qr=True) + uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way) scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" await self._safe_callback( on_login_qr_update, @@ -219,6 +242,8 @@ class Server864RuntimeMixin: "provider_stage": "waiting_scan", "connection_ready": False, "login_required": True, + "login_qr_api": login_qr_api, + "login_way": login_way, }, logger=logger, callback_name="on_login_qr_update",