Compare commits

...

10 Commits

7 changed files with 878 additions and 239 deletions

View File

@@ -476,6 +476,65 @@ def api_login_qr_status():
return jsonify({"success": False, "error": str(e)}), 500
@robot_bp.route('/api/login_qr_mode', methods=['POST'])
@login_required
def api_switch_login_qr_mode():
"""切换 864 登录二维码模式,并重启登录流程。"""
try:
server = current_app.dashboard_server
robot = getattr(server, "robot", None)
if robot is None:
return jsonify({"success": False, "error": "机器人实例不可用"}), 500
payload = request.get_json(silent=True) or {}
login_qr_api = str(payload.get("login_qr_api", "") or "").strip()
login_way = str(payload.get("login_way", "") or "").strip()
result = robot.switch_server_864_login_entry(
login_qr_api=login_qr_api,
login_way=login_way or None,
# 切换二维码模式只调整后续登录入口,不主动踢掉服务端当前在线会话:
# 1. 用户已经明确要求“服务端已登录时,除非手动执行,否则不要做 logout”
# 2. 这里改为仅重启本地 provider 线程,让 ABOT 重新接管当前会话或等待下次手动退出后再使用新模式;
# 3. 真正的远端退出动作只保留在 `/api/logout_864`,语义更清晰。
do_logout=False,
)
return jsonify({
"success": True,
"message": "864 登录二维码模式已切换,新的登录线程正在启动",
"data": {
**result,
"login_qr_state": _serialize_login_qr_state(server),
},
})
except Exception as e:
LOG.error(f"切换 864 登录二维码模式失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@robot_bp.route('/api/logout_864', methods=['POST'])
@login_required
def api_logout_864():
"""退出当前 864 登录态,并重新进入二维码登录引导。"""
try:
server = current_app.dashboard_server
robot = getattr(server, "robot", None)
if robot is None:
return jsonify({"success": False, "error": "机器人实例不可用"}), 500
result = robot.logout_server_864_and_restart_login()
return jsonify({
"success": True,
"message": "864 已退出当前登录态,新的二维码登录流程正在启动",
"data": {
**result,
"login_qr_state": _serialize_login_qr_state(server),
},
})
except Exception as e:
LOG.error(f"退出 864 登录态失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# API路由
@robot_bp.route('/api/groups')
@login_required

View File

@@ -124,12 +124,13 @@
<el-dialog
title="微信登录二维码"
:visible.sync="loginQrDialog.visible"
:visible="shouldShowLoginQrDialog"
width="760px"
class="login-qr-dialog"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false">
:close-on-press-escape="false"
@update:visible="handleLoginQrDialogVisibleChange">
<div class="login-qr-dialog__body" v-loading="loginQrDialog.loading">
<div class="login-qr-dialog__hero">
<div class="login-qr-dialog__preview">
@@ -171,6 +172,10 @@
<span>UUID</span>
<strong>{% raw %}{{ loginQrCurrent.uuid || '-' }}{% endraw %}</strong>
</div>
<div class="login-qr-dialog__meta-item">
<span>登录方式</span>
<strong>{% raw %}{{ loginQrWayText }}{% endraw %}</strong>
</div>
<div class="login-qr-dialog__meta-item">
<span>原始状态</span>
<strong>{% raw %}{{ formatLoginQrRawState(loginQrCurrent.raw_state) }}{% endraw %}</strong>
@@ -182,6 +187,28 @@
</div>
<div class="login-qr-dialog__actions">
<el-button size="mini" @click="loadLoginQrStatus(true)">立即刷新状态</el-button>
<el-button
v-if="isServer864Login"
size="mini"
:loading="loginQrActionLoading === 'harmony_api'"
@click="switch864LoginMode('harmony_api', 'harmony')">
鸿蒙二维码
</el-button>
<el-button
v-if="isServer864Login"
size="mini"
:loading="loginQrActionLoading === 'new'"
@click="switch864LoginMode('new')">
标准New二维码
</el-button>
<el-button
v-if="isServer864Login"
size="mini"
type="danger"
:loading="loginQrActionLoading === 'logout_864'"
@click="logout864Login">
退出864登录
</el-button>
<el-button v-if="loginQrCurrent.scan_url" type="text" @click="copyLoginQrScanUrl">
复制扫码链接
</el-button>
@@ -581,6 +608,7 @@
server_now: 0
},
loginQrCountdownSeconds: 0,
loginQrActionLoading: '',
groups: [],
selectedGroupForHourlyTrend: '',
hourlyTrendDays: 1,
@@ -617,9 +645,23 @@
loginQrCurrent() {
return this.loginQrDialog.current || {};
},
isServer864Login() {
const providerName = String(this.loginQrDialog.provider_name || '').trim().toLowerCase();
return providerName === 'server_864' || providerName === '864';
},
showLoginQrBanner() {
return !this.loginQrDialog.logged_in;
},
shouldShowLoginQrDialog() {
// 首页登录弹窗的展示规则尽量简单且稳定:
// 1. 只要当前还没登录成功,就必须强制显示二维码弹窗,避免前端局部状态抖动把弹窗意外关掉;
// 2. 登录成功后再退回到本地 visible 控制,兼容后续如果需要保留“手动再次查看”的空间;
// 3. 这样能直接满足“没有可用账号,就一直要求扫码登录”的目标,不再依赖多处代码同步开关。
if (!this.loginQrDialog.logged_in) {
return true;
}
return !!this.loginQrDialog.visible;
},
loginQrStatusTone() {
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return 'soft';
@@ -696,6 +738,21 @@
}
return '登录引导中';
},
loginQrWayText() {
const normalizedWay = String(this.loginQrCurrent.login_way || '').trim().toLowerCase();
const wayMap = {
mac: 'mac',
win: 'win',
harmony: 'harmony',
car: 'car',
watch: 'watch'
};
// 弹窗里直接显示当前实际使用的登录终端形态:
// 1. 864 的二维码接口会根据 `Way` 走不同的登录链路,联调时很容易忘记当前到底用的是哪一档;
// 2. 用户已经明确在 `.env` 中切到了 `WECHAT_LOGIN_WAY=harmony`,这里展示出来能立刻确认配置有没有真的生效;
// 3. 若后续 provider 回传了别的兼容值,也保留原文,避免前端把未知新值错误折叠掉。
return wayMap[normalizedWay] || normalizedWay || '-';
},
loginQrCountdownText() {
if (this.loginQrDialog.logged_in) {
return '已登录';
@@ -882,6 +939,17 @@
openLoginQrDialog() {
this.loginQrDialog.visible = true;
},
handleLoginQrDialogVisibleChange(nextVisible) {
// 未登录时弹窗不允许被真正关闭:
// 1. Element UI 仍可能在内部触发 `update:visible` 事件;
// 2. 如果这里直接接受 false就会再次出现“业务要求必须常驻但组件自己收起”的问题
// 3. 因此只有在已登录成功后,才允许同步关闭状态。
if (!this.loginQrDialog.logged_in) {
this.loginQrDialog.visible = true;
return;
}
this.loginQrDialog.visible = !!nextVisible;
},
applyLoginQrState(state) {
const nextState = state || {};
const current = nextState.current || {};
@@ -930,6 +998,63 @@
this.loginQrDialog.loading = false;
});
},
switch864LoginMode(loginQrApi, loginWay = '') {
if (!this.isServer864Login) {
this.$message.warning('当前不是 864 登录模式,暂不支持切换二维码入口');
return;
}
this.loginQrActionLoading = loginQrApi;
axios.post('/robot/api/login_qr_mode', {
login_qr_api: loginQrApi,
login_way: loginWay
})
.then(response => {
if (response.data.success) {
const state = (((response.data || {}).data || {}).login_qr_state) || {};
if (state && Object.keys(state).length > 0) {
this.applyLoginQrState(state);
}
this.$message.success(response.data.message || '864 登录二维码入口已切换');
setTimeout(() => this.loadLoginQrStatus(true), 1200);
return;
}
this.$message.error(response.data.error || '切换 864 登录二维码入口失败');
})
.catch(error => {
const errorMessage = (((error || {}).response || {}).data || {}).error || '切换 864 登录二维码入口失败';
this.$message.error(errorMessage);
})
.finally(() => {
this.loginQrActionLoading = '';
});
},
logout864Login() {
if (!this.isServer864Login) {
this.$message.warning('当前不是 864 登录模式,暂不支持此操作');
return;
}
this.loginQrActionLoading = 'logout_864';
axios.post('/robot/api/logout_864')
.then(response => {
if (response.data.success) {
const state = (((response.data || {}).data || {}).login_qr_state) || {};
if (state && Object.keys(state).length > 0) {
this.applyLoginQrState(state);
}
this.$message.success(response.data.message || '864 已退出当前登录态');
setTimeout(() => this.loadLoginQrStatus(true), 1200);
return;
}
this.$message.error(response.data.error || '退出 864 登录态失败');
})
.catch(error => {
const errorMessage = (((error || {}).response || {}).data || {}).error || '退出 864 登录态失败';
this.$message.error(errorMessage);
})
.finally(() => {
this.loginQrActionLoading = '';
});
},
tickLoginQrCountdown() {
if (this.loginQrDialog.logged_in) {
this.loginQrCountdownSeconds = 0;

141
robot.py
View File

@@ -525,8 +525,13 @@ class Robot:
provider_stage = str(self.ipad_login_qr_state.get("provider_stage", "bootstrap") or "bootstrap").strip()
connection_ready = bool(self.ipad_login_qr_state.get("connection_ready", False))
login_required = bool(self.ipad_login_qr_state.get("login_required", False))
# 首页弹窗是否关闭,必须优先以“当前二维码运行态”本身为准:
# 1. 之前这里把 `self.wxid` 也算进了 logged_in导致旧身份缓存会把新一轮扫码流程误判成“已经登录”
# 2. 864 收口失败后虽然 provider 已经回到扫码引导态,但 Robot 上残留的旧 wxid 仍会让前端直接收起弹窗;
# 3. 因此这里收紧为只认当前二维码状态机明确给出的 logged_in / confirmed 信号,不再混入历史缓存身份。
logged_in = login_state_flag or (qr_status in {"confirmed", "logged_in"} and not login_required)
state = {
"logged_in": bool(self.wxid) or login_state_flag or qr_status in {"confirmed", "logged_in"},
"logged_in": logged_in,
"active": bool(self.ipad_login_qr_state.get("active", False)),
"status": qr_status,
"provider_name": provider_name,
@@ -577,6 +582,12 @@ class Robot:
head_img_url = str((payload or {}).get("head_img_url", "") or "").strip()
expires_in = (payload or {}).get("expires_in")
expires_in = None if expires_in in (None, "") else max(0, int(expires_in))
# 一旦重新进入扫码链路,就主动清掉 Robot 侧残留的账号身份:
# 1. 864 登录收口失败时provider 已明确告诉前端“当前没有可用账号”,此时继续保留旧 wxid 会误导首页状态;
# 2. Dashboard 的当前账号、健康卡片、二维码弹窗都共享这份运行态缓存,必须确保未登录时不再展示历史账号;
# 3. 这里只在非成功态下清空,不影响真正登录完成后的账号信息展示。
if status not in {"confirmed", "logged_in"}:
self._clear_ipad_identity_cache()
current_record = {
"uuid": uuid_value,
"scan_url": scan_url,
@@ -615,6 +626,24 @@ class Robot:
"updated_at": now_ts,
}
def _clear_ipad_identity_cache(self) -> None:
"""清理 Robot 侧缓存的 wechat 账号身份,避免未登录时误显示旧号。"""
self.wxid = ""
self.nickname = ""
self.alias = ""
self.phone = ""
self.signature = ""
# `ipad_bot` 可能还未初始化完成,因此这里做一次存在性保护:
# 1. 登录引导状态会在 provider 创建早期就被推给 Dashboard
# 2. 这时 bot 对象不一定已经完整可用,不能因为清理缓存反而引入新的 AttributeError
# 3. 所以只在对象存在时尽量同步清空,做成纯兜底动作。
if self.ipad_bot is not None:
self.ipad_bot.wxid = ""
self.ipad_bot.nickname = ""
self.ipad_bot.alias = ""
self.ipad_bot.phone = ""
self.ipad_bot.signature = ""
async def _handle_ipad_login_qr_cleared(self, payload: dict | None = None) -> None:
"""在登录完成或识别到已有登录态后关闭首页二维码引导。"""
now_ts = time.time()
@@ -652,6 +681,13 @@ class Robot:
2. provider 不应该知道本项目有哪些数据库表、后台缓存或插件系统;
3. 因此登录“流程”放到 provider登录后的“业务初始化”继续留在 Robot。
"""
# 这里再做一次项目侧兜底校验:
# 1. provider 已经会尽量保证只有“拿到可用身份”才会调进来;
# 2. 但 Robot 这一层承接的是联系人同步、插件注入、消息归档等重业务动作,不能接受空账号继续执行;
# 3. 因此只要 `wxid/nickname` 都为空,就立刻阻断后台初始化,强制回到扫码登录流程。
if not str(login_identity.get("wxid", "") or "").strip() and not str(login_identity.get("nickname", "") or "").strip():
raise RuntimeError("当前未拿到可用登录账号身份,已阻止进入后台初始化流程")
self.wxid = login_identity.get("wxid", "")
self.nickname = login_identity.get("nickname", "")
self.alias = login_identity.get("alias", "")
@@ -891,9 +927,110 @@ class Robot:
if hasattr(self, "ipad_bot") and self.ipad_bot and hasattr(self.ipad_bot, "stop_runtime"):
self.ipad_bot.stop_runtime()
if self.ipad_loop:
self.ipad_loop.stop()
try:
# 事件循环运行在独立线程里,主线程这里需要走线程安全停止:
# 1. 直接 `loop.stop()` 在某些时机会留下竞态,导致旧线程迟迟不退出;
# 2. Dashboard 现在需要支持“不重启主进程,直接切换 864 登录模式”;
# 3. 因此这里改成 `call_soon_threadsafe`,让旧 provider 能更稳地收尾退出。
self.ipad_loop.call_soon_threadsafe(self.ipad_loop.stop)
except Exception:
self.ipad_loop.stop()
if self.ipad_thread and self.ipad_thread.is_alive():
self.ipad_thread.join(timeout=5)
self.ipad_thread = None
self.ipad_loop = None
self.ipad_bot = None
self.LOG.info("wechat_ipad客户端已停止")
def switch_server_864_login_entry(self, *, login_qr_api: str, login_way: str | None = None, do_logout: bool = True) -> dict:
"""切换 864 登录入口并重启登录流程。
设计说明:
1. 用户希望在后台直接切换“鸿蒙专用二维码”和“标准 New 二维码”,而不是改 `.env` 后重启整套服务;
2. 当前 864 runtime 在独立线程里长驻运行,最稳妥的切换方式是“更新配置 -> 退出旧登录态 -> 重启 provider 登录线程”;
3. 这样实现虽然比纯运行时热切换更直接,但代码层次更浅,也更便于后续继续接其他 server 变体。
"""
if not isinstance(self.ipad_config, dict):
raise RuntimeError("wechat_ipad 运行时配置尚未初始化,暂时无法切换 864 登录入口")
current_server_type = self._normalize_wechat_provider_key(self.ipad_config.get("server_type", "legacy_855"))
if current_server_type != "server_864":
raise RuntimeError("当前仅支持在 server_864 模式下切换登录二维码入口")
normalized_login_qr_api = str(login_qr_api or "").strip().lower()
if normalized_login_qr_api not in {"harmony_api", "new", "new_x"}:
raise ValueError(f"不支持的 864 登录入口模式: {login_qr_api}")
normalized_login_way = str(login_way or self.ipad_config.get("login_way", "mac") or "mac").strip().lower()
if normalized_login_qr_api == "harmony_api":
normalized_login_way = "harmony"
if do_logout and self.ipad_bot and self.ipad_loop:
try:
logout_future = asyncio.run_coroutine_threadsafe(self.ipad_bot.log_out(), self.ipad_loop)
logout_future.result(timeout=20)
except Exception as e:
# 切换登录模式时,退出旧会话失败不应阻断后续重启:
# 1. 某些 864 版本在会话已失效时会直接返回错误;
# 2. 用户真正关心的是“后台能否尽快切到新的二维码入口”;
# 3. 因此这里记录日志后继续向下执行重启流程。
self.LOG.warning(f"切换 864 登录入口前执行退出请求失败,继续重启登录流程: {e}")
# `do_logout=False` 时只重启 ABOT 本地 provider不会主动登出远端在线账号
# 1. 这样切换二维码模式不会误把当前服务端现有会话踢下线;
# 2. 若远端本来就已在线,新的 provider 线程会优先尝试接管现有登录态;
# 3. 真正的远端退出只保留给手动点击“退出864登录”的显式动作。
self.ipad_config["login_qr_api"] = normalized_login_qr_api
self.ipad_config["login_way"] = normalized_login_way
if isinstance(getattr(self.config, "wechat_ipad", None), dict):
self.config.wechat_ipad["login_qr_api"] = normalized_login_qr_api
self.config.wechat_ipad["login_way"] = normalized_login_way
self._clear_ipad_identity_cache()
switch_label_map = {
"harmony_api": "864 鸿蒙专用二维码",
"new": "864 标准 New 二维码",
"new_x": f"864 NewX 二维码({normalized_login_way}",
}
with self._ipad_login_qr_lock:
self.ipad_login_qr_state = {
"logged_in": False,
"active": True,
"status": "waiting",
"provider_name": "server_864",
"provider_stage": "login_required",
"connection_ready": False,
"login_required": True,
"status_text": f"正在切换到{switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api)},请稍候刷新二维码",
"current": {
"login_qr_api": normalized_login_qr_api,
"login_way": normalized_login_way,
"updated_at": time.time(),
"updated_at_text": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
},
"history": [],
"updated_at": time.time(),
}
self.stop_wechat_ipad()
if not self.init_wechat_ipad():
raise RuntimeError("重启 wechat_ipad 登录线程失败,请检查 864 服务端与配置")
return {
"server_type": "server_864",
"login_qr_api": normalized_login_qr_api,
"login_way": normalized_login_way,
"message": switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api),
}
def logout_server_864_and_restart_login(self) -> dict:
"""退出当前 864 登录态,并按现有二维码入口重新进入登录引导。"""
return self.switch_server_864_login_entry(
login_qr_api=str(self.ipad_config.get("login_qr_api", "new_x") or "new_x"),
login_way=str(self.ipad_config.get("login_way", "mac") or "mac"),
do_logout=True,
)
def keep_running_and_block_process(self) -> None:
"""
保持机器人运行,不让进程退出

View File

@@ -64,7 +64,19 @@ class Server864APIClientBase:
params=merged_params,
json=json_body,
) as response:
payload = await response.json(content_type=None)
try:
payload = await response.json(content_type=None)
except Exception:
raw_text = await response.text()
# 864 少数接口会直接返回纯文本而不是标准 DTO
# 1. 当前已在 `LogOut` 链路里碰到这种情况,旧逻辑会先炸 JSON 解析,再把真正状态信息吞掉;
# 2. 对 2xx 响应来说,这通常只是“接口风格不统一”,不应该直接视为致命协议错误;
# 3. 因此这里统一包成兼容 DTO保留原始文本给上层按需处理。
payload = {
"Code": 200 if response.status < 400 else response.status,
"Data": None,
"Text": str(raw_text or "").strip(),
}
return self._validate_payload(payload)
async def _request_data(
@@ -96,6 +108,12 @@ class Server864APIClientBase:
return payload
message = str(payload.get("Text") or payload.get("Message") or "server_864 请求失败").strip()
# 864 某些登录接口会用非 200 编码表达“当前 key 已经在线”:
# 1. 对二维码申请链路来说,这更接近一种状态回执,而不是硬失败;
# 2. 如果这里直接抛异常,上层就会把“已经在线”误判成致命错误并退出线程;
# 3. 因此先保留 payload 原样放行,让 runtime 再决定是复用现有登录态还是继续走扫码。
if "该链接已绑定微信号" in message and "在线状态良好" in message:
return payload
lowered_message = message.lower()
if any(keyword in lowered_message for keyword in ("重新登录", "已退出登录", "离线", "账号需要重新登录")):
raise UserLoggedOut(message)

View File

@@ -7,6 +7,70 @@ 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 参数。"""
@@ -57,7 +121,19 @@ class LoginMixin(Server864APIClientBase):
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"}:
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 变体;
@@ -78,6 +154,14 @@ class LoginMixin(Server864APIClientBase):
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",
@@ -146,14 +230,29 @@ class LoginMixin(Server864APIClientBase):
async def get_login_status(self, auto_login: bool = True) -> dict:
"""获取 864 在线状态。"""
return await self._request_data(
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 登录态。"""
await self._request_data("get", "/login/LogOutRequest", timeout=15)
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

View File

@@ -28,6 +28,57 @@ class Server864RuntimeMixin:
def is_runtime_running(self) -> bool:
return bool(getattr(self, "_runtime_running", False))
async def _wait_login_identity_ready(
self,
*,
logger,
on_login_qr_update: AsyncCallback | None = None,
uuid: str = "",
url: str = "",
scan_url: str = "",
login_qr_api: str = "new_x",
login_way: str = "mac",
retry_times: int = 12,
retry_interval_seconds: int = 5,
) -> bool:
"""在 864 已判定在线后,继续等待资料接口把账号身份补齐。"""
for attempt in range(1, retry_times + 1):
if await self._refresh_identity_from_profile(logger=logger):
return True
try:
login_status = await self.get_login_status(auto_login=False)
except Exception as e:
logger.warning(f"server_864 等待账号资料就绪时获取登录状态失败: {e}")
else:
if self._is_online_from_login_status_payload(login_status):
# 服务端已在线但资料还没取到时,前端不应继续停在“未登录”语义:
# 1. 用户当前最困惑的点正是“明明在线了,为什么页面还不显示登录成功”;
# 2. 这类场景更接近“服务端在线,账号资料同步中”,需要一个更准确的中间态提示;
# 3. 因此这里主动把状态更新到“资料同步中”,让首页知道并非扫码失败。
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"status": "confirmed",
"status_text": f"864 服务端已在线,正在同步账号资料(第 {attempt}/{retry_times} 次)",
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "login_finalizing",
"connection_ready": True,
"login_required": False,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
if attempt < retry_times:
await asyncio.sleep(retry_interval_seconds)
return False
@staticmethod
def _normalize_login_runtime_message(message: str) -> str:
"""把 864 登录阶段抛出的原始错误整理成更适合前端展示的短文本。"""
@@ -101,7 +152,14 @@ class Server864RuntimeMixin:
login_way=login_way,
)
await on_login_ready(self.get_login_identity())
login_identity = self.get_login_identity()
# 在 provider 侧再做一次硬校验:
# 1. `_ensure_login()` 已经负责等待真正可用的登录态,但运行期代码后续可能继续演进;
# 2. 这里补一道收口保护,可以避免未来某次重构把“空身份”再次漏进 Robot 业务层;
# 3. 一旦这里失败,说明当前 provider 还没有拿到稳定账号身份,必须继续停留在登录阶段。
if not self._has_login_identity(login_identity):
raise RuntimeError("server_864 登录流程未拿到可用账号身份,已阻止进入后台业务初始化")
await on_login_ready(login_identity)
logger.info("server_864 登录成功")
await self._set_runtime_running(True, on_runtime_state_change=on_runtime_state_change, logger=logger)
@@ -146,7 +204,14 @@ class Server864RuntimeMixin:
) -> None:
"""确保 864 已完成登录。"""
if await self.is_logged_in():
await self._refresh_identity_from_profile(logger=logger)
identity_ready = await self._wait_login_identity_ready(
logger=logger,
on_login_qr_update=on_login_qr_update,
login_qr_api=login_qr_api,
login_way=login_way,
)
if not identity_ready:
raise RuntimeError("当前未拿到可用账号身份,请重新扫码登录")
# 864 在“服务端已经在线、ABOT 只是后启动”的场景下会直接走这里:
# 1. 之前这条分支只清理二维码态,没有补写本地 runtime_state
# 2. 这会让用户误以为“明明已经登录成功,却没有生成 provider 状态文件”;
@@ -173,50 +238,299 @@ class Server864RuntimeMixin:
)
return
# 先探测一次 864 当前阶段,让 Dashboard 能直接区分“等服务端准备”和“需要扫码”:
# 1. 864 的未登录态并不只有一种,部分场景其实是远端连接对象还没建好;
# 2. 若首页始终只显示“未登录”,运维很难判断下一步是等服务端还是去扫码;
# 3. 这里把差异压缩成轻量阶段字段,供前端直接展示,不改动核心登录流程。
login_stage_snapshot = await self._probe_login_stage()
await self._safe_callback(
on_login_qr_update,
login_stage_snapshot,
logger=logger,
callback_name="on_login_qr_update",
)
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,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": None,
"status": "waiting",
"status_text": "等待扫码登录",
"login_source": "fresh_qr",
"provider_name": "server_864",
"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",
)
while True:
# 这里把“申请二维码 -> 轮询扫码 -> 登录收口”包成一轮完整会话:
# 1. 864 某些失败不是用户操作问题,而是服务端在扫码后主动中断当前连接;
# 2. 对这类场景,最合理的行为不是让线程退出,而是把错误留在前端并回到下一轮扫码;
# 3. 这样可以满足“没登录成功就不要进入后台,但页面持续引导重新登录”的产品预期。
login_stage_snapshot = await self._probe_login_stage()
await self._safe_callback(
on_login_qr_update,
login_stage_snapshot,
logger=logger,
callback_name="on_login_qr_update",
)
try:
is_logged_in, login_status = await self.check_login_status()
uuid, url = await self.get_qr_code(print_qr=True, login_qr_api=login_qr_api, login_way=login_way)
except Exception as e:
error_message = self._normalize_login_runtime_message(str(e))
# 登录轮询阶段一旦出现明确错误,应立刻同步到 Dashboard
# 1. 之前这类异常只会停在控制台日志,前端继续显示旧的“等待扫码/等待验证”文案;
# 2. 用户已经扫码后,最需要的是第一时间知道“为什么卡住了”
# 3. 因此这里把错误直接回写成登录态,让弹窗成为当前环境登录的真实看板。
qr_error_message = str(e).strip()
if "该链接已绑定微信号" in qr_error_message and "在线状态良好" in qr_error_message:
# 当 864 服务端明确提示“当前 key 已在线”时,优先尝试直接复用现有登录态:
# 1. 这通常出现在切换二维码模式过快、旧连接尚未完全退出的瞬间
# 2. 若此时直接抛错,线程会把“已经在线”误判成获取二维码失败;
# 3. 因此这里先转成“尝试接管现有会话”,成功后直接收口,不再强制继续申请新二维码。
identity_ready = await self._refresh_identity_from_profile(logger=logger)
if identity_ready:
ipad_config["wxid"] = self.wxid
ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self._save_runtime_state(
state_path=state_path,
state_payload={"wxid": self.wxid, "login_time": ipad_config["login_time"]},
logger=logger,
)
await self._safe_callback(
on_login_qr_cleared,
{
"status": "logged_in",
"status_text": "864 服务端已存在可用登录态,已自动复用当前会话",
"provider_name": "server_864",
"provider_stage": "logged_in",
"connection_ready": True,
"login_required": False,
},
logger=logger,
callback_name="on_login_qr_cleared",
)
return
await self._safe_callback(
on_login_qr_update,
{
"status": "unavailable",
"status_text": self._normalize_login_runtime_message(qr_error_message),
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "status_unavailable",
"connection_ready": False,
"login_required": True,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
raise
scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else ""
effective_time = 0
raw_state = 0
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": None,
"status": "waiting",
"status_text": "等待扫码登录",
"login_source": "fresh_qr",
"provider_name": "server_864",
"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",
)
login_completed = False
while True:
try:
is_logged_in, login_status = await self.check_login_status()
except Exception as e:
error_message = self._normalize_login_runtime_message(str(e))
# 轮询阶段失败时只更新前端,不中断 provider 主线程:
# 1. 线上最常见的是本轮登录连接已被服务端丢弃,继续崩线程只会让运维反复重启;
# 2. 这里先把明确错误展示给前端,再回到外层重新申请二维码;
# 3. 这样用户在新环境里可以直接继续扫码,不需要手工重启 ABOT。
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"status": "unavailable",
"status_text": error_message,
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "status_unavailable",
"connection_ready": False,
"login_required": True,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
logger.warning(f"server_864 登录状态轮询失败,本轮二维码作废并等待重新登录: {error_message}")
await asyncio.sleep(2)
break
if is_logged_in:
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": effective_time if effective_time > 0 else None,
"status": "confirmed",
# 864 进入 `state=2` 后,已经拿到了 wxid / wxnewpass 等关键登录信息:
# 1. 这说明扫码本身已经成功,但并不代表 ABOT 后续初始化已经全部完成;
# 2. 若这里直接关闭弹窗,后面的版本过低/初始化失败就无法同步给前端;
# 3. 因此先保持弹窗可见,把阶段切到“登录收口中”,等真正初始化完成后再清理。
"status_text": "扫码成功,等待服务端完成登录",
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "login_finalizing",
"connection_ready": True,
"login_required": False,
"raw_state": max(raw_state, 2),
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
login_completed = True
break
# 864 的登录状态查询会回传当前 uuid 和有效期:
# 1. 真实联调中已确认 `CheckLoginStatus` 会返回 `uuid/effective_time`
# 2. 这些值应优先作为 Dashboard 的二维码倒计时与当前扫码目标来源;
# 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)
raw_state = int(login_status.get("raw_state", login_status.get("state", 0)) or 0)
verification_url = str(login_status.get("verification_url", "") or "").strip()
nick_name = str(login_status.get("nick_name", "") or "").strip()
head_img_url = str(login_status.get("head_img_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
# 864 的 `CheckLoginStatus` 存在一类“状态缓存已终态,但服务端连接没有继续推进”的情况:
# 1. 实测里扫码并完成安全验证后,接口可能停在 `state=4 + VerificationUrl`,前端会一直看见旧状态;
# 2. 结合服务端源码,这类 `state=4` 更接近终态/失效态,而不是可继续等待的中间态;
# 3. 因此这里把它单独识别出来,避免 Dashboard 长时间卡在“等待安全验证”的旧文案。
raw_status_text = str(login_status.get("msg") or login_status.get("loginState") or "").strip()
if raw_state == 0:
# 864 有时会把上一轮登录留下的 VerificationUrl 一起带回来:
# 1. 但当 `raw_state=0` 时,真实含义仍然是“当前二维码等待扫码”;
# 2. 若继续优先展示旧验证链接,前端就会出现“明明没扫却提示去安全验证”的误导;
# 3. 因此这里明确以原始状态为准,并主动忽略这类陈旧 VerificationUrl。
provider_stage = "waiting_scan" if uuid else "login_required"
status = "waiting"
status_text = raw_status_text or "等待扫码登录"
verification_url = ""
nick_name = ""
head_img_url = ""
elif raw_state == 4:
provider_stage = "login_required"
status = "expired"
if verification_url:
status_text = "安全验证链路已结束,但服务端未完成登录收口,正在准备刷新二维码"
else:
status_text = raw_status_text or "二维码状态已结束,正在准备刷新二维码"
elif raw_state == 1:
# 864 在 `state=1` 时通常已经识别出扫码账号,但还没推进到最终登录完成:
# 1. 这时接口经常会同时回传昵称、头像以及 VerificationUrl
# 2. 但从用户体验上看,它更接近“已扫码,等待服务端确认”,不应该继续只提示去点链接;
# 3. 因此这里单独映射成可读阶段,方便 Dashboard 展示更准确的过程状态。
provider_stage = "scan_confirmed"
status = "waiting"
display_name = nick_name or "当前微信账号"
status_text = raw_status_text or f"已扫码:{display_name},等待服务端确认登录"
elif verification_url:
provider_stage = "verification_required"
status = "waiting"
if not raw_status_text:
status_text = "扫码已完成,请继续打开验证链接完成安全验证"
else:
status_text = raw_status_text
else:
provider_stage = "waiting_scan" if uuid else "login_required"
status = "waiting"
status_text = raw_status_text or "等待扫码登录"
if verification_url and raw_state not in {0, 4} and not raw_status_text:
status_text = "扫码已完成,请继续打开验证链接完成安全验证"
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": effective_time if effective_time > 0 else None,
"status": status,
"status_text": status_text,
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": provider_stage,
"connection_ready": False,
"login_required": True,
"verification_url": verification_url,
"raw_state": raw_state,
"nick_name": nick_name,
"head_img_url": head_img_url,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
# 若 server 已明确告知二维码失效或停在终态缓存,则立即结束本轮、回到外层申请新二维码:
# 1. 这能避免 Dashboard 一直展示一张已经不可扫的旧二维码;
# 2. 864 某些版本即使 `effective_time` 还大于 0也可能已经停在 `state=4` 终态缓存里;
# 3. 因此这里补充 `raw_state == 4` 的刷新条件,让前端能尽快得到新的登录入口。
if effective_time <= 0 or raw_state == 4:
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": None,
"status": "waiting",
"status_text": "二维码已失效,正在准备新二维码",
"login_source": "refresh_qr",
"provider_name": "server_864",
"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",
)
break
await asyncio.sleep(5)
if not login_completed:
continue
try:
await self._wait_init_ready(logger=logger)
identity_ready = await self._wait_login_identity_ready(
logger=logger,
on_login_qr_update=on_login_qr_update,
uuid=uuid,
url=url,
scan_url=scan_url,
login_qr_api=login_qr_api,
login_way=login_way,
)
if not identity_ready:
raise RuntimeError("扫码完成后未获取到可用账号身份,请重新扫码登录")
ipad_config["wxid"] = self.wxid
ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self._save_runtime_state(
state_path=state_path,
state_payload={"wxid": self.wxid, "login_time": ipad_config["login_time"]},
logger=logger,
)
return
except Exception as e:
error_message = self._normalize_post_scan_failure_message(str(e))
# 扫码成功后的收口阶段同样需要把异常同步给前端:
# 1. 这类错误往往发生在“state=2 之后但正式进入业务前”,最容易被误判成前端没刷新;
# 2. 例如当前用户遇到的“客户端版本过低”就是在这个阶段由服务端主动断开;
# 3. 这里不再直接抛异常退出,而是把错误留在弹窗里并继续等待下一轮扫码。
await self._safe_callback(
on_login_qr_update,
{
@@ -230,194 +544,25 @@ class Server864RuntimeMixin:
"provider_stage": "status_unavailable",
"connection_ready": False,
"login_required": True,
"raw_state": max(raw_state, 2),
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
raise RuntimeError(error_message) from e
if is_logged_in:
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": effective_time if effective_time > 0 else None,
"status": "confirmed",
# 864 进入 `state=2` 后,已经拿到了 wxid / wxnewpass 等关键登录信息:
# 1. 这说明扫码本身已经成功,但并不代表 ABOT 后续初始化已经全部完成;
# 2. 若这里直接关闭弹窗,后面的版本过低/初始化失败就无法同步给前端;
# 3. 因此先保持弹窗可见,把阶段切到“登录收口中”,等真正初始化完成后再清理。
"status_text": "扫码成功,等待服务端完成登录",
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "login_finalizing",
"connection_ready": True,
"login_required": False,
"raw_state": 2,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
break
logger.warning(f"server_864 登录收口失败,继续停留在扫码引导态等待重新登录: {error_message}")
await asyncio.sleep(2)
# 864 的登录状态查询会回传当前 uuid 和有效期:
# 1. 真实联调中已确认 `CheckLoginStatus` 会返回 `uuid/effective_time`
# 2. 这些值应优先作为 Dashboard 的二维码倒计时与当前扫码目标来源;
# 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)
raw_state = int(login_status.get("raw_state", login_status.get("state", 0)) or 0)
verification_url = str(login_status.get("verification_url", "") or "").strip()
nick_name = str(login_status.get("nick_name", "") or "").strip()
head_img_url = str(login_status.get("head_img_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
# 864 的 `CheckLoginStatus` 存在一类“状态缓存已终态,但服务端连接没有继续推进”的情况:
# 1. 实测里扫码并完成安全验证后,接口可能停在 `state=4 + VerificationUrl`,前端会一直看见旧状态;
# 2. 结合服务端源码,这类 `state=4` 更接近终态/失效态,而不是可继续等待的中间态;
# 3. 因此这里把它单独识别出来,避免 Dashboard 长时间卡在“等待安全验证”的旧文案。
raw_status_text = str(login_status.get("msg") or login_status.get("loginState") or "").strip()
if raw_state == 0:
# 864 有时会把上一轮登录留下的 VerificationUrl 一起带回来:
# 1. 但当 `raw_state=0` 时,真实含义仍然是“当前二维码等待扫码”;
# 2. 若继续优先展示旧验证链接,前端就会出现“明明没扫却提示去安全验证”的误导;
# 3. 因此这里明确以原始状态为准,并主动忽略这类陈旧 VerificationUrl。
provider_stage = "waiting_scan" if uuid else "login_required"
status = "waiting"
status_text = raw_status_text or "等待扫码登录"
verification_url = ""
nick_name = ""
head_img_url = ""
elif raw_state == 4:
provider_stage = "login_required"
status = "expired"
if verification_url:
status_text = "安全验证链路已结束,但服务端未完成登录收口,正在准备刷新二维码"
else:
status_text = raw_status_text or "二维码状态已结束,正在准备刷新二维码"
elif raw_state == 1:
# 864 在 `state=1` 时通常已经识别出扫码账号,但还没推进到最终登录完成:
# 1. 这时接口经常会同时回传昵称、头像以及 VerificationUrl
# 2. 但从用户体验上看,它更接近“已扫码,等待服务端确认”,不应该继续只提示去点链接;
# 3. 因此这里单独映射成可读阶段,方便 Dashboard 展示更准确的过程状态。
provider_stage = "scan_confirmed"
status = "waiting"
display_name = nick_name or "当前微信账号"
status_text = raw_status_text or f"已扫码:{display_name},等待服务端确认登录"
elif verification_url:
provider_stage = "verification_required"
status = "waiting"
if not raw_status_text:
status_text = "扫码已完成,请继续打开验证链接完成安全验证"
else:
status_text = raw_status_text
else:
provider_stage = "waiting_scan" if uuid else "login_required"
status = "waiting"
status_text = raw_status_text or "等待扫码登录"
if verification_url and raw_state not in {0, 4} and not raw_status_text:
status_text = "扫码已完成,请继续打开验证链接完成安全验证"
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": effective_time if effective_time > 0 else None,
"status": status,
"status_text": status_text,
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": provider_stage,
"connection_ready": False,
"login_required": True,
"verification_url": verification_url,
"raw_state": raw_state,
"nick_name": nick_name,
"head_img_url": head_img_url,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
# 若 server 已明确告知二维码失效或停在终态缓存,则立即重新申请一张新码:
# 1. 这能避免 Dashboard 一直展示一张已经不可扫的旧二维码;
# 2. 864 某些版本即使 `effective_time` 还大于 0也可能已经停在 `state=4` 终态缓存里;
# 3. 因此这里补充 `raw_state == 4` 的刷新条件,让前端能尽快得到新的登录入口。
if effective_time <= 0 or raw_state == 4:
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,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"expires_in": None,
"status": "waiting",
"status_text": "二维码已刷新,等待扫码登录",
"login_source": "refresh_qr",
"provider_name": "server_864",
"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",
)
await asyncio.sleep(5)
try:
await self._wait_init_ready(logger=logger)
await self._refresh_identity_from_profile(logger=logger)
ipad_config["wxid"] = self.wxid
ipad_config["login_time"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self._save_runtime_state(
state_path=state_path,
state_payload={"wxid": self.wxid, "login_time": ipad_config["login_time"]},
logger=logger,
)
except Exception as e:
error_message = self._normalize_post_scan_failure_message(str(e))
# 扫码成功后的收口阶段同样需要把异常同步给前端:
# 1. 这类错误往往发生在“state=2 之后但正式进入业务前”,最容易被误判成前端没刷新;
# 2. 例如当前用户遇到的“客户端版本过低”就是在这个阶段由服务端主动断开;
# 3. 因此这里明确把阶段标成 `status_unavailable`,让弹窗继续停留并展示真实原因。
await self._safe_callback(
on_login_qr_update,
{
"uuid": uuid,
"url": url,
"scan_url": scan_url,
"status": "unavailable",
"status_text": error_message,
"login_source": "fresh_qr",
"provider_name": "server_864",
"provider_stage": "status_unavailable",
"connection_ready": False,
"login_required": True,
"raw_state": 2,
"login_qr_api": login_qr_api,
"login_way": login_way,
},
logger=logger,
callback_name="on_login_qr_update",
)
raise RuntimeError(error_message) from e
@staticmethod
def _has_login_identity(login_identity: dict[str, Any] | None) -> bool:
"""判断当前 provider 是否已经拿到可用于进入业务层的账号身份。"""
payload = dict(login_identity or {})
# 这里把“可用身份”的标准压到最小:
# 1. 864 有些接口可能先拿到昵称、后拿到 wxid也可能顺序相反
# 2. 但两者都为空时,业务层无法知道当前到底是谁登录了;
# 3. 因此至少要拿到 `wxid` 或 `nickname` 之一,才允许继续进入 Robot 初始化。
return bool(str(payload.get("wxid", "") or "").strip() or str(payload.get("nickname", "") or "").strip())
async def _probe_login_stage(self) -> dict[str, Any]:
"""探测 864 当前登录阶段,供 Dashboard 展示更准确的运维状态。"""
@@ -495,18 +640,46 @@ class Server864RuntimeMixin:
logger.warning(f"server_864 检查初始化状态失败: {error_message}")
await asyncio.sleep(2)
async def _refresh_identity_from_profile(self, *, logger) -> None:
async def _refresh_identity_from_profile(self, *, logger) -> bool:
"""从 864 的资料接口刷新当前登录身份。"""
try:
profile = await self.get_profile()
except Exception as e:
error_message = str(e).strip()
# `GetProfile` 失败时,再用 `GetLoginStatus` 补做一次登录态与身份判定:
# 1. 用户当前联调里已经确认 `GetLoginStatus` 可用,而 `GetProfile` / `GetInItStatus` 在部分阶段会直接报“该链接不存在”;
# 2. 若此时登录状态接口其实还能返回在线和账号字段,就没必要仅因资料接口异常而误判整轮登录失败;
# 3. 因此这里把它作为资料接口失败后的第一优先补偿探针,尽量保住已经完成的登录链路。
try:
login_status = await self.get_login_status(auto_login=False)
except Exception as status_error:
logger.warning(
f"server_864 登录状态补偿探针也失败,无法确认当前账号身份: {error_message}; "
f"GetLoginStatus={status_error}"
)
else:
if self._is_online_from_login_status_payload(login_status):
identity = self._extract_login_identity_from_status(login_status)
self.wxid = identity.get("wxid", self.wxid)
self.nickname = identity.get("nickname", self.nickname)
self.alias = identity.get("alias", self.alias)
self.phone = identity.get("phone", self.phone)
self.signature = identity.get("signature", self.signature)
if self.wxid or self.nickname:
logger.info(
"server_864 资料接口失败,但已通过 GetLoginStatus 补确认当前账号身份: "
f"wxid={self.wxid} nickname={self.nickname}"
)
return True
# 864 有些版本在消息链路可用后,资料接口仍可能短时间不可用:
# 1. 此时若直接抛异常,会让“已经登录成功”的启动流程被资料查询反向拖垮;
# 2. 项目主链路真正依赖的是后续消息同步与发送能力,而不是这里的展示性资料
# 3. 所以这里改成降级告警,保留已有身份字段,等后续再由可用接口补齐
logger.warning(f"server_864 刷新登录账号资料失败,继续沿用当前缓存身份: {error_message}")
return
# 2. 但如果当前连 `wxid/nickname` 都没有,就不能再假装“已经有可用身份”
# 3. 因此这里返回布尔值,由上层决定是继续使用已知身份,还是把登录流程判定为失败
if self.wxid or self.nickname:
logger.warning(f"server_864 刷新登录账号资料失败,继续沿用当前已获取身份: {error_message}")
return True
logger.warning(f"server_864 刷新登录账号资料失败,当前尚未拿到可用账号身份: {error_message}")
return False
self.wxid = str(
profile.get("UserName")
or profile.get("userName")
@@ -530,6 +703,7 @@ class Server864RuntimeMixin:
logger.info(
f"server_864 登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}"
)
return bool(self.wxid or self.nickname)
@staticmethod
def _save_runtime_state(*, state_path: str, state_payload: dict[str, Any], logger) -> None:

View File

@@ -1,5 +1,7 @@
from loguru import logger
from wechat_ipad.errors import UserLoggedOut
from wechat_ipad.providers.server_864.base import Server864APIClientBase
@@ -39,8 +41,33 @@ class UserMixin(Server864APIClientBase):
"""检查 864 当前账号是否在线。"""
del wxid
try:
# 优先使用 864 自己的登录状态接口判断在线态:
# 1. `GetProfile` 在某些版本里会比真实登录态更早失效,容易把“已登录但资料接口异常”误判成未登录;
# 2. 用户当前给出的 `GetLoginStatus` 正是更贴近 server 自身会话状态的一条探针;
# 3. 因此这里先走登录状态接口,只有它也无法确认时,才回退到资料接口兜底。
login_status = await self.get_login_status(auto_login=False)
if self._is_online_from_login_status_payload(login_status):
identity = self._extract_login_identity_from_status(login_status)
if identity.get("wxid"):
self.wxid = identity["wxid"]
if identity.get("nickname"):
self.nickname = identity["nickname"]
if identity.get("alias"):
self.alias = identity["alias"]
if identity.get("phone"):
self.phone = identity["phone"]
if identity.get("signature"):
self.signature = identity["signature"]
return True
await self.get_profile()
return True
except Exception as e:
logger.error("server_864 is_logged_in:{}", e)
except UserLoggedOut as e:
# “未登录 / 需要重新登录”是 864 登录引导中的常规状态,不应该长期污染 error 日志:
# 1. 当前首页会主动引导扫码,这类返回本质上只是“当前还没登录完成”;
# 2. 若仍按 error 记录,运维排查时很难分清真正异常和正常登录态切换;
# 3. 因此这里降级为 info只保留可读状态文本。
logger.info("server_864 is_logged_in:{}", e)
return False
except Exception as e:
logger.warning("server_864 is_logged_in:{}", e)
return False