Spaces:
Paused
Paused
File size: 15,020 Bytes
bbd7bd6 616dbd6 bbd7bd6 34164ac bbd7bd6 860a4f0 bbd7bd6 860a4f0 f82773a 5978acc 860a4f0 f82773a bf58c8d bbd7bd6 120638a fcb2d84 bf58c8d bbd7bd6 d0b6cac 34164ac bbd7bd6 d0b6cac bbd7bd6 d0b6cac bbd7bd6 d0b6cac bbd7bd6 d0b6cac bbd7bd6 d0b6cac bbd7bd6 462f5f6 bbd7bd6 462f5f6 bbd7bd6 fcb2d84 bbd7bd6 616dbd6 3df0958 616dbd6 3df0958 616dbd6 3df0958 bbd7bd6 462f5f6 bbd7bd6 34164ac bbd7bd6 54c478e bbd7bd6 54c478e 616dbd6 bbd7bd6 54c478e f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 f82773a de7cad3 54c478e 860a4f0 54c478e 860a4f0 54c478e 860a4f0 54c478e f82773a de7cad3 54c478e 860a4f0 54c478e 860a4f0 616dbd6 54c478e 616dbd6 860a4f0 54c478e 860a4f0 616dbd6 54c478e 860a4f0 54c478e 860a4f0 616dbd6 860a4f0 616dbd6 54c478e 616dbd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | """首次启动初始化向导 — 交互式填写 .env 中的必填配置"""
import logging
import os
import re
import secrets
import sys
from autoteam.config import PROJECT_ROOT
from autoteam.textio import parse_env_line, read_text, write_text
logger = logging.getLogger(__name__)
ENV_FILE = PROJECT_ROOT / ".env"
ENV_EXAMPLE = PROJECT_ROOT / ".env.example"
# 需要交互式输入的配置项(key, 提示, 默认值, 是否可选)
# CLOUDMAIL_EMAIL 已废弃 → 不再列入 REQUIRED_CONFIGS(cf_temp_email 后端只看 PASSWORD,
# maillab 后端用 MAILLAB_USERNAME 替代)
REQUIRED_CONFIGS = [
(
"MAIL_PROVIDER",
"Mail Provider(cf_temp_email = dreamhunter2333 临时邮箱; maillab = maillab/cloud-mail)",
"cf_temp_email",
True,
),
# cf_temp_email 字段(provider=cf_temp_email 时必填,api.get_setup_status 按 provider 动态切 optional)
("CLOUDMAIL_BASE_URL", "CloudMail API 地址(cf_temp_email 后端;Pages 前端域名带 /api,Worker 直连域名填根路径不带 /api)", "", False),
("CLOUDMAIL_PASSWORD", "CloudMail 管理员密码(cf_temp_email 后端)", "", False),
("CLOUDMAIL_DOMAIN", "邮箱域名(如 @example.com)", "", False),
# maillab 字段(SPEC-1 §3.4;provider=maillab 时必填,默认 optional 由 setup_status 动态切换)
("MAILLAB_API_URL", "Maillab API 地址(maillab 后端)", "", True),
("MAILLAB_USERNAME", "Maillab 管理员邮箱(maillab 后端)", "", True),
("MAILLAB_PASSWORD", "Maillab 管理员密码(maillab 后端)", "", True),
("MAILLAB_DOMAIN", "Maillab 邮箱域名(缺省回落 CLOUDMAIL_DOMAIN)", "", True),
("CPA_URL", "CPA (CLIProxyAPI) 地址", "http://127.0.0.1:8317", False),
("CPA_KEY", "CPA 管理密钥", "", False),
("ROTATE_NEW_ACCOUNT_MODE", "新号创建策略(domain_auto_join_first/invite_first/direct_first)", "domain_auto_join_first", True),
("AUTOTEAM_AUTO_JOIN_DOMAINS", "自动入工作空间域名(auto 或逗号分隔域名)", "auto", True),
("ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE", "直接注册失败后回退邀请(true/false)", "true", True),
("PLAYWRIGHT_PROXY_URL", "Playwright 浏览器代理 URL(可选,如 socks5://host:port)", "", True),
("PLAYWRIGHT_PROXY_BYPASS", "Playwright 代理绕过列表(可选,如 localhost,127.0.0.1)", "", True),
("API_KEY", "API 鉴权密钥(回车自动生成)", "", False),
]
def _read_env() -> dict[str, str]:
"""读取 .env 文件为 dict"""
result = {}
if ENV_FILE.exists():
for line in read_text(ENV_FILE).splitlines():
parsed = parse_env_line(line)
if parsed:
key, value = parsed
result[key] = value
return result
def _write_env(key: str, value: str):
"""写入或更新 .env 中的某个 key"""
if ENV_FILE.exists():
content = read_text(ENV_FILE)
pattern = rf"^{re.escape(key)}=.*$"
if re.search(pattern, content, re.MULTILINE):
content = re.sub(pattern, f"{key}={value}", content, flags=re.MULTILINE)
else:
content = content.rstrip() + f"\n{key}={value}\n"
write_text(ENV_FILE, content)
else:
# 从 .env.example 复制再写入
if ENV_EXAMPLE.exists():
content = read_text(ENV_EXAMPLE)
pattern = rf"^{re.escape(key)}=.*$"
if re.search(pattern, content, re.MULTILINE):
content = re.sub(pattern, f"{key}={value}", content, flags=re.MULTILINE)
write_text(ENV_FILE, content)
else:
write_text(ENV_FILE, f"{key}={value}\n")
def _is_interactive() -> bool:
"""检测是否有终端交互能力(Docker 等非交互环境返回 False)"""
try:
return sys.stdin.isatty()
except Exception:
return False
def check_and_setup(interactive: bool = True) -> bool:
"""
检查必填配置是否齐全,缺失时交互式提示输入。
返回 True 表示配置完整,False 表示用户中断或非交互模式下缺配置。
"""
interactive = interactive and _is_interactive()
env = _read_env()
missing = []
for key, prompt, default, optional in REQUIRED_CONFIGS:
val = env.get(key, "") or os.environ.get(key, "")
if not val and not optional:
missing.append((key, prompt, default, optional))
if not missing:
# 配置齐全,每次启动验证连通性
_skip = os.environ.get("AUTOTEAM_SKIP_VERIFY", "").strip().lower() in ("1", "true", "yes")
if not _verify_cloudmail():
if _skip:
logger.warning("[验证] CloudMail 验证失败,已根据 AUTOTEAM_SKIP_VERIFY 继续启动")
else:
logger.error("[验证] CloudMail 配置有误,请修改 .env 后重新启动(或设置 AUTOTEAM_SKIP_VERIFY=1 跳过)")
sys.exit(1)
if not _verify_cpa():
if _skip:
logger.warning("[验证] CPA 验证失败,已根据 AUTOTEAM_SKIP_VERIFY 继续启动")
else:
logger.error("[验证] CPA 配置有误,请修改 .env 后重新启动(或设置 AUTOTEAM_SKIP_VERIFY=1 跳过)")
sys.exit(1)
return True
if not interactive:
for key, prompt, _, _ in missing:
logger.warning("[配置] 缺少必填项: %s (%s)", key, prompt)
logger.warning("[配置] 请通过 Web 面板或编辑 .env 文件填入配置")
return False
print("\n=== AutoTeam 首次配置 ===\n")
print("检测到以下配置项需要填写,直接回车使用默认值(如有):\n")
for key, prompt, default, optional in missing:
hint = f" [{default}]" if default else ""
if key == "API_KEY":
hint = " [回车自动生成]"
try:
value = input(f" {prompt}{hint}: ").strip()
except KeyboardInterrupt:
print("\n\n已取消配置。")
raise SystemExit(130)
if not value:
if key == "API_KEY":
value = secrets.token_urlsafe(24)
print(f" -> 已自动生成: {value}")
elif default:
value = default
print(f" -> 使用默认值: {value}")
elif not optional:
print(" -> 跳过(必填项,后续可在 .env 中补充)")
continue
if value:
_write_env(key, value)
# 同步到当前进程的环境变量
os.environ[key] = value
print("\n配置已保存到 .env\n")
# 重新加载 config 和依赖模块
import importlib
import autoteam.config
importlib.reload(autoteam.config)
try:
import autoteam.cloudmail
importlib.reload(autoteam.cloudmail)
except Exception:
pass
# 验证配置连通性
if not _verify_cloudmail():
logger.error("[验证] CloudMail 配置有误,请修改 .env 后重新启动")
sys.exit(1)
if not _verify_cpa():
logger.error("[验证] CPA 配置有误,请修改 .env 后重新启动")
sys.exit(1)
return True
def _sniff_provider_mismatch(provider: str) -> tuple[bool, str]:
"""SPEC-1 §3.4 — 探测 base_url 与 MAIL_PROVIDER 的匹配性。
返回 (matched, reason):
- matched=True:可继续 login(reason 为空字符串或 warning 内容)
- matched=False:_verify_cloudmail 应直接 return False
探测点:
- GET /setting/websiteConfig — maillab 独有,通常含 domainList
- GET /admin/address — cf_temp_email 独有,401/403/200 视为"路由活跃"
- GET /login — maillab POST 路由,GET 期望非 404
"""
import requests
base = ""
if provider in ("cf_temp_email", "cloudflare_temp_email", ""):
base = (os.environ.get("CLOUDMAIL_BASE_URL") or "").rstrip("/")
elif provider == "maillab":
base = (os.environ.get("MAILLAB_API_URL") or "").rstrip("/")
if not base:
return True, ""
# 探测 1:maillab /setting/websiteConfig (无需 token)
websiteconfig_alive = False
websiteconfig_has_domainlist = False
try:
r_wc = requests.get(f"{base}/setting/websiteConfig", timeout=5)
if r_wc.status_code == 200:
try:
data = r_wc.json() or {}
if isinstance(data, dict):
websiteconfig_alive = True
websiteconfig_has_domainlist = "domainList" in data
except Exception:
pass
except Exception:
pass
# 探测 2:cf_temp_email /admin/address (无 admin auth 时期望 401/403,或 200 含 results)
admin_route_alive = False
try:
r_admin = requests.get(f"{base}/admin/address", timeout=5)
admin_route_alive = r_admin.status_code in (200, 401, 403)
except Exception:
pass
# 探测 3:maillab /login (用 GET 探测路由存在性)
login_route_alive = False
try:
r_login = requests.get(f"{base}/login", timeout=5)
login_route_alive = r_login.status_code != 404
except Exception:
pass
# 推断 detected_provider
if websiteconfig_alive or login_route_alive:
detected = "maillab"
elif admin_route_alive:
detected = "cf_temp_email"
else:
detected = "unknown"
if provider in ("cf_temp_email", "cloudflare_temp_email", ""):
if detected == "maillab":
return False, (
f"CLOUDMAIL_BASE_URL={base} 看起来是 maillab/cloud-mail 服务器"
f"(/setting/websiteConfig {'存在 domainList' if websiteconfig_has_domainlist else '可达'} / /login 路由活跃)。"
"请改 MAIL_PROVIDER=maillab 并补齐 MAILLAB_API_URL/USERNAME/PASSWORD/DOMAIN。"
)
elif provider == "maillab":
if detected == "cf_temp_email":
return False, (
f"MAILLAB_API_URL={base} 看起来是 dreamhunter2333/cloudflare_temp_email 服务器"
"(/setting/websiteConfig 不可达,但 /admin/address 活跃)。"
"请改 MAIL_PROVIDER=cf_temp_email 并配置 CLOUDMAIL_BASE_URL/PASSWORD/DOMAIN。"
)
return True, ""
def _verify_cloudmail():
"""验证 mail provider 配置:登录 + 创建测试邮箱 + 删除。
根据 MAIL_PROVIDER 自动走对应分支:
- cf_temp_email(默认):需要 CLOUDMAIL_BASE_URL / CLOUDMAIL_PASSWORD / CLOUDMAIL_DOMAIN
- maillab:需要 MAILLAB_API_URL / MAILLAB_USERNAME / MAILLAB_PASSWORD / MAILLAB_DOMAIN(或回落 CLOUDMAIL_DOMAIN)
"""
provider = (os.environ.get("MAIL_PROVIDER") or "cf_temp_email").strip().lower()
if provider in ("cf_temp_email", "cloudflare_temp_email", ""):
base_url = os.environ.get("CLOUDMAIL_BASE_URL", "")
password = os.environ.get("CLOUDMAIL_PASSWORD", "")
domain = os.environ.get("CLOUDMAIL_DOMAIN", "")
if not all([base_url, password, domain]):
return
check_keys = "CLOUDMAIL_BASE_URL、CLOUDMAIL_PASSWORD"
domain_key = "CLOUDMAIL_DOMAIN"
label = "CloudMail (cf_temp_email)"
elif provider == "maillab":
api_url = os.environ.get("MAILLAB_API_URL", "")
username = os.environ.get("MAILLAB_USERNAME", "")
password = os.environ.get("MAILLAB_PASSWORD", "")
domain = os.environ.get("MAILLAB_DOMAIN") or os.environ.get("CLOUDMAIL_DOMAIN", "")
if not all([api_url, username, password, domain]):
return
check_keys = "MAILLAB_API_URL、MAILLAB_USERNAME、MAILLAB_PASSWORD"
domain_key = "MAILLAB_DOMAIN"
label = "maillab"
else:
logger.error("[验证] 未知 MAIL_PROVIDER=%s,可选: cf_temp_email | maillab", provider)
return False
logger.info("[验证] %s 配置...", label)
# SPEC-1 §3.4 — 嗅探前置 + 强阻断:base_url 路由指纹与 MAIL_PROVIDER 不一致时,
# 直接 return False,**不**实例化 client。AUTOTEAM_SKIP_PROVIDER_SNIFF=1 可跳过(逃生口)。
if os.environ.get("AUTOTEAM_SKIP_PROVIDER_SNIFF") != "1":
matched, reason = _sniff_provider_mismatch(provider)
if not matched:
logger.error("[验证] 协议错配: %s", reason)
return False
try:
from autoteam.cloudmail import CloudMailClient
client = CloudMailClient()
client.login()
logger.info("[验证] %s 登录成功", label)
except Exception as e:
logger.error("[验证] %s 登录失败: %s", label, e)
logger.error("[验证] 请检查 %s", check_keys)
return False
test_account_id = None
try:
import uuid as _uuid
test_account_id, test_email = client.create_temp_email(prefix=f"at-test-{_uuid.uuid4().hex[:6]}")
logger.info("[验证] %s 创建测试邮箱成功: %s", label, test_email)
except Exception as e:
logger.error("[验证] %s 创建邮箱失败: %s", label, e)
logger.error("[验证] 请检查 %s 是否正确", domain_key)
return False
try:
if test_account_id:
client.delete_account(test_account_id)
logger.info("[验证] %s 测试邮箱已清理", label)
except Exception as e:
logger.warning("[验证] %s 清理测试邮箱失败: %s(不影响使用)", label, e)
logger.info("[验证] %s 配置验证通过", label)
return True
def _verify_cpa():
"""验证 CPA 配置是否正确:获取认证文件列表"""
cpa_url = os.environ.get("CPA_URL", "")
cpa_key = os.environ.get("CPA_KEY", "")
if not cpa_url or not cpa_key:
return True # 没配就跳过
logger.info("[验证] CPA 配置...")
try:
import requests
resp = requests.get(
f"{cpa_url}/v0/management/auth-files",
headers={"Authorization": f"Bearer {cpa_key}"},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
count = len(data.get("files", []))
logger.info("[验证] CPA 连接成功(当前 %d 个认证文件)", count)
return True
if resp.status_code == 401:
logger.error("[验证] CPA 连接失败: 密钥无效 (401)")
logger.error("[验证] 请检查 CPA_KEY 是否正确")
return False
logger.error("[验证] CPA 连接失败: HTTP %d", resp.status_code)
logger.error("[验证] 请检查 CPA_URL 是否正确")
return False
except requests.exceptions.ConnectionError:
logger.error("[验证] CPA 连接失败: 无法连接到 %s", cpa_url)
logger.error("[验证] 请检查 CPA_URL 是否正确,CPA 服务是否已启动")
return False
except Exception as e:
logger.error("[验证] CPA 连接失败: %s", e)
return False
|