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",