"""负责账号结果、pending 恢复以及 grok2api token 池的安全持久化。""" import json import os import tempfile import time from contextlib import ExitStack from datetime import datetime, timezone from filelock import FileLock def append_account_line(path, email, password, sso): with open(path, "a", encoding="utf-8") as handle: handle.write(f"{email}----{password}----{sso}\n") handle.flush() os.fsync(handle.fileno()) def save_mail_credential(base_dir, email, credential): path = os.path.join(base_dir, "mail_credentials.txt") with open(path, "a", encoding="utf-8") as handle: handle.write(f"{email}\t{credential}\n") handle.flush() os.fsync(handle.fileno()) return True def queue_unsaved_account(path, payload, error): pending_path = path + ".pending.jsonl" record = dict(payload) record["save_error"] = str(error) record["queued_at"] = datetime.now(timezone.utc).isoformat() with open(pending_path, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() os.fsync(handle.fileno()) try: os.chmod(pending_path, 0o600) except Exception: pass return True def _existing_account_keys(target_path): keys = set() if not os.path.isfile(target_path): return keys with open(target_path, "r", encoding="utf-8", errors="replace") as handle: for raw_line in handle: parts = raw_line.rstrip("\n").split("----", 2) if len(parts) == 3: keys.add((parts[0].strip(), parts[2].strip())) return keys def retry_pending_file(pending_path, output_path=None, log_callback=None): logger = log_callback or (lambda message: None) pending_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(pending_path)))) if not os.path.isfile(pending_path): raise FileNotFoundError(f"pending 文件不存在: {pending_path}") suffix = ".pending.jsonl" if output_path: target_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(output_path)))) elif pending_path.endswith(suffix): target_path = os.path.realpath(pending_path[:-len(suffix)]) else: target_path = os.path.realpath(pending_path + ".recovered.txt") if os.path.normcase(pending_path) == os.path.normcase(target_path): raise ValueError("pending 输入文件与输出文件不能是同一个文件") lock_paths = sorted( {pending_path + ".lock", target_path + ".lock"}, key=lambda value: os.path.normcase(os.path.abspath(value)), ) with ExitStack() as stack: for lock_path in lock_paths: stack.enter_context(FileLock(lock_path, timeout=30)) if not os.path.isfile(pending_path): return {"restored": 0, "remaining": 0, "output_path": target_path} with open(pending_path, "r", encoding="utf-8") as handle: lines = handle.readlines() existing = _existing_account_keys(target_path) unresolved = [] restored = 0 for line_number, raw_line in enumerate(lines, 1): stripped = raw_line.strip() if not stripped: continue try: record = json.loads(stripped) if not isinstance(record, dict): raise ValueError("record must be a JSON object") email = str(record.get("email") or "").strip() password = str(record.get("password") or "") sso = str(record.get("sso") or "").strip() if not email or not sso: raise ValueError("record missing email or sso") key = (email, sso) if key not in existing: append_account_line(target_path, email, password, sso) existing.add(key) restored += 1 logger(f"[+] 已恢复 pending 账号: {email}") except Exception as exc: unresolved.append(raw_line if raw_line.endswith("\n") else raw_line + "\n") logger(f"[!] pending 第 {line_number} 行恢复失败: {exc}") directory = os.path.dirname(pending_path) or "." fd, temp_path = tempfile.mkstemp(prefix=".pending-retry-", suffix=".jsonl.tmp", dir=directory) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.writelines(unresolved) handle.flush() os.fsync(handle.fileno()) if unresolved: os.replace(temp_path, pending_path) temp_path = None try: os.chmod(pending_path, 0o600) except Exception: pass else: os.unlink(temp_path) temp_path = None try: os.unlink(pending_path) except FileNotFoundError: pass finally: if temp_path and os.path.exists(temp_path): os.unlink(temp_path) return {"restored": restored, "remaining": len(unresolved), "output_path": target_path} # Token-pool runtime dependencies are injected by the application adapter. config = {} _http_get = None _http_post = None _log_exception = None _remote_compat_error = RuntimeError _remote_request_error = RuntimeError def configure_token_runtime(config_ref, http_get, http_post, log_exception, compatibility_error=RuntimeError, request_error=RuntimeError): global config, _http_get, _http_post, _log_exception global _remote_compat_error, _remote_request_error config = config_ref _http_get = http_get _http_post = http_post _log_exception = log_exception _remote_compat_error = compatibility_error _remote_request_error = request_error globals()["http_get"] = http_get globals()["http_post"] = http_post globals()["log_exception"] = log_exception globals()["RemoteTokenCompatibilityError"] = compatibility_error globals()["RemoteTokenRequestError"] = request_error def resolve_grok2api_local_token_file(): configured = str(config.get("grok2api_local_token_file", "") or "").strip() if configured: return configured return os.path.join(os.path.dirname(__file__), "token.json") def _normalize_sso_token(raw_token): token = str(raw_token or "").strip() if token.startswith("sso="): token = token[4:] return token def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None): token = _normalize_sso_token(raw_token) if not token: return False token_file = os.path.abspath(resolve_grok2api_local_token_file()) pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic" parent = os.path.dirname(token_file) os.makedirs(parent, exist_ok=True) lock_path = token_file + ".lock" try: with open(lock_path, "a", encoding="utf-8"): pass os.chmod(lock_path, 0o600) except Exception: pass try: from filelock import FileLock except Exception as exc: raise RuntimeError(f"filelock 依赖不可用,拒绝非原子写入 token 池: {exc}") with FileLock(lock_path, timeout=30): data = {} if os.path.exists(token_file): try: with open(token_file, "r", encoding="utf-8") as f: data = json.load(f) or {} except Exception as exc: broken_path = token_file + f".broken-{int(time.time())}" try: os.replace(token_file, broken_path) except Exception: broken_path = token_file raise RuntimeError(f"本地 token 文件 JSON 解析失败,已停止写入以避免覆盖: {broken_path}: {exc}") if not isinstance(data, dict): raise RuntimeError("本地 token 文件根节点不是 JSON object,拒绝覆盖") pool = data.get(pool_name) if pool is None: pool = [] elif not isinstance(pool, list): raise RuntimeError(f"本地 token 池 {pool_name} 不是列表,拒绝覆盖") existing = set() for item in pool: if isinstance(item, str): existing.add(_normalize_sso_token(item)) elif isinstance(item, dict): existing.add(_normalize_sso_token(item.get("token", ""))) if token in existing: if log_callback: log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}") return True pool.append({"token": token, "tags": ["auto-register"], "note": email}) data[pool_name] = pool if os.path.exists(token_file): backup_path = token_file + ".bak" try: with open(token_file, "rb") as src, open(backup_path, "wb") as dst: dst.write(src.read()) dst.flush() os.fsync(dst.fileno()) try: os.chmod(backup_path, 0o600) except Exception: pass except Exception as exc: raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}") fd, temp_path = tempfile.mkstemp(prefix=".token-", suffix=".tmp", dir=parent) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") f.flush() os.fsync(f.fileno()) try: os.chmod(temp_path, 0o600) except Exception: pass os.replace(temp_path, token_file) temp_path = None try: os.chmod(token_file, 0o600) except Exception: pass finally: if temp_path and os.path.exists(temp_path): try: os.unlink(temp_path) except Exception: pass if log_callback: log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})") return True def get_grok2api_remote_api_bases(base): """生成 grok2api 管理 API 候选根路径。 参数: - base str: 用户配置的 grok2api 远端地址 返回: - list[str]: 依次尝试的管理 API 根路径 """ normalized = str(base or "").strip().rstrip("/") if not normalized: return [] lower = normalized.lower() candidates = [normalized] if lower.endswith("/admin/api"): return candidates if lower.endswith("/admin"): candidates.append(f"{normalized}/api") else: candidates.append(f"{normalized}/admin/api") seen = set() unique = [] for item in candidates: if item not in seen: unique.append(item) seen.add(item) return unique def add_token_to_grok2api_remote_pool(raw_token, email="", log_callback=None): token = _normalize_sso_token(raw_token) if not token: return False base = str(config.get("grok2api_remote_base", "") or "").strip().rstrip("/") app_key = str(config.get("grok2api_remote_app_key", "") or "").strip() pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() if not base or not app_key: raise RemoteTokenRequestError("grok2api 远端未配置 base/app_key") headers = {"Content-Type": "application/json"} query = {"app_key": app_key} remote_pool = {"ssoBasic": "basic", "ssoSuper": "super"}[pool_name] api_bases = get_grok2api_remote_api_bases(base) incompatible = [] add_payload = {"tokens": [token], "pool": remote_pool, "tags": ["auto-register"]} for api_base in api_bases: endpoint = f"{api_base}/tokens/add" try: response = http_post(endpoint, headers=headers, params=query, json=add_payload, timeout=30) except Exception as exc: raise RemoteTokenRequestError(f"远端 /tokens/add 网络请求失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if 200 <= status < 300: if log_callback: log_callback(f"[+] 已写入 grok2api 远端池: {pool_name} ({endpoint})") return True if status in (404, 405): incompatible.append(f"{endpoint}: HTTP {status}") continue body = str(getattr(response, "text", "") or "")[:300] raise RemoteTokenRequestError(f"远端 /tokens/add 请求失败,不允许全量回退: {endpoint}: HTTP {status}: {body}") if not bool(config.get("grok2api_allow_legacy_full_save", False)): raise RemoteTokenCompatibilityError( "/tokens/add 不受支持,旧版全量保存默认禁用以避免并发覆盖: " + "; ".join(incompatible) ) current = None fallback_base = None etag = None load_errors = [] for api_base in api_bases or [base]: endpoint = f"{api_base}/tokens" try: response = http_get(endpoint, headers=headers, params=query, timeout=20) except Exception as exc: raise RemoteTokenRequestError(f"旧版远端池读取网络失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if status != 200: load_errors.append(f"{endpoint}: HTTP {status}") continue payload = response.json() candidate = payload.get("tokens") if isinstance(payload, dict) and "tokens" in payload else payload if not isinstance(candidate, dict): load_errors.append(f"{endpoint}: unexpected payload") continue current = candidate fallback_base = api_base response_headers = getattr(response, "headers", {}) or {} etag = response_headers.get("ETag") or response_headers.get("etag") break if current is None or fallback_base is None: raise RemoteTokenRequestError("无法安全读取旧版远端 token 池: " + "; ".join(load_errors)) pool = current.get(pool_name) if pool is None: pool = [] elif not isinstance(pool, list): raise RemoteTokenRequestError(f"远端 token 池 {pool_name} 不是列表,拒绝全量覆盖") existing = { _normalize_sso_token(item if isinstance(item, str) else item.get("token", "")) for item in pool if isinstance(item, (str, dict)) } if token not in existing: pool.append({"token": token, "tags": ["auto-register"], "note": email}) current[pool_name] = pool if not etag: raise RemoteTokenCompatibilityError( "旧版远端接口未提供 ETag,无法保证并发安全,已拒绝全量保存" ) save_headers = dict(headers) save_headers["If-Match"] = etag endpoint = f"{fallback_base}/tokens" try: response = http_post(endpoint, headers=save_headers, params=query, json=current, timeout=30) except Exception as exc: raise RemoteTokenRequestError(f"旧版远端池保存网络失败: {endpoint}: {exc}") from exc status = int(getattr(response, "status_code", 0) or 0) if not 200 <= status < 300: raise RemoteTokenRequestError(f"旧版远端池保存失败: {endpoint}: HTTP {status}") if log_callback: log_callback(f"[+] 已写入 grok2api 远端池(旧版兼容): {pool_name} ({endpoint})") return True def add_token_to_grok2api_pools(raw_token, email="", log_callback=None): result = { "local": {"enabled": bool(config.get("grok2api_auto_add_local", False)), "ok": None, "error": None}, "remote": {"enabled": bool(config.get("grok2api_auto_add_remote", False)), "ok": None, "error": None}, } if result["local"]["enabled"]: try: result["local"]["ok"] = bool(add_token_to_grok2api_local_pool(raw_token, email=email, log_callback=log_callback)) except Exception as exc: result["local"]["ok"] = False result["local"]["error"] = log_exception("写入 grok2api 本地池失败", exc, log_callback) if result["remote"]["enabled"]: try: result["remote"]["ok"] = bool(add_token_to_grok2api_remote_pool(raw_token, email=email, log_callback=log_callback)) except Exception as exc: result["remote"]["ok"] = False result["remote"]["error"] = log_exception("写入 grok2api 远端池失败", exc, log_callback) return result