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