diff --git a/config.py b/config.py index 06a72bb..c4fc8eb 100644 --- a/config.py +++ b/config.py @@ -211,6 +211,110 @@ def save_team_json(): _log_config("ERROR", "team.json", "保存失败", str(e)) return False + +def reload_config() -> dict: + """重新加载配置文件 (config.toml 和 team.json) + + Returns: + dict: 重载结果,包含 success, teams_count, config_updated, message + """ + global _cfg, _raw_teams, TEAMS + global EMAIL_PROVIDER, INCLUDE_TEAM_OWNERS, AUTH_PROVIDER + global BROWSER_HEADLESS, ACCOUNTS_PER_TEAM + global GPTMAIL_API_KEYS, GPTMAIL_DOMAINS, GPTMAIL_PREFIX + global PROXY_ENABLED, PROXIES + global S2A_API_BASE, S2A_ADMIN_KEY, S2A_ADMIN_TOKEN + global S2A_CONCURRENCY, S2A_PRIORITY, S2A_GROUP_NAMES, S2A_GROUP_IDS + + result = { + "success": True, + "teams_count": 0, + "config_updated": False, + "message": "" + } + + errors = [] + + # 重载 config.toml + try: + new_cfg = _load_toml() + if new_cfg: + _cfg = new_cfg + result["config_updated"] = True + + # 更新关键配置变量 + EMAIL_PROVIDER = _cfg.get("email_provider", "kyx") + + # 授权服务选择 + AUTH_PROVIDER = _cfg.get("auth_provider") or _cfg.get("gptmail", {}).get("auth_provider", "crs") + + # Owner 入库开关 + _include_owners_top = _cfg.get("include_team_owners") + _include_owners_gptmail = _cfg.get("gptmail", {}).get("include_team_owners") + INCLUDE_TEAM_OWNERS = _include_owners_top if _include_owners_top is not None else (_include_owners_gptmail if _include_owners_gptmail is not None else False) + + # 浏览器配置 + _browser = _cfg.get("browser", {}) + BROWSER_HEADLESS = _browser.get("headless", False) + + # 账号配置 + _account = _cfg.get("account", {}) + ACCOUNTS_PER_TEAM = _account.get("accounts_per_team", 4) + + # GPTMail 配置 + _gptmail = _cfg.get("gptmail", {}) + GPTMAIL_PREFIX = _gptmail.get("prefix", "") + GPTMAIL_DOMAINS = _gptmail.get("domains", []) + _gptmail_api_key = _gptmail.get("api_key", "") + _gptmail_api_keys = _gptmail.get("api_keys", []) + GPTMAIL_API_KEYS = _gptmail_api_keys if _gptmail_api_keys else ([_gptmail_api_key] if _gptmail_api_key else ["gpt-test"]) + + # 代理配置 + _proxy_enabled_top = _cfg.get("proxy_enabled") + _proxy_enabled_browser = _cfg.get("browser", {}).get("proxy_enabled") + PROXY_ENABLED = _proxy_enabled_top if _proxy_enabled_top is not None else (_proxy_enabled_browser if _proxy_enabled_browser is not None else False) + + _proxies_top = _cfg.get("proxies") + _proxies_browser = _cfg.get("browser", {}).get("proxies") + PROXIES = (_proxies_top if _proxies_top is not None else (_proxies_browser if _proxies_browser is not None else [])) if PROXY_ENABLED else [] + + # S2A 配置 + _s2a = _cfg.get("s2a", {}) + S2A_API_BASE = _s2a.get("api_base", "") + S2A_ADMIN_KEY = _s2a.get("admin_key", "") + S2A_ADMIN_TOKEN = _s2a.get("admin_token", "") + S2A_CONCURRENCY = _s2a.get("concurrency", 10) + S2A_PRIORITY = _s2a.get("priority", 50) + S2A_GROUP_NAMES = _s2a.get("group_names", []) + S2A_GROUP_IDS = _s2a.get("group_ids", []) + + except Exception as e: + errors.append(f"config.toml: {e}") + + # 重载 team.json + try: + new_raw_teams = _load_teams() + _raw_teams = new_raw_teams + + # 重新解析 TEAMS + TEAMS.clear() + for i, t in enumerate(_raw_teams): + team_config = _parse_team_config(t, i) + TEAMS.append(team_config) + + result["teams_count"] = len(TEAMS) + + except Exception as e: + errors.append(f"team.json: {e}") + + if errors: + result["success"] = False + result["message"] = "; ".join(errors) + else: + result["message"] = "配置重载成功" + + return result + # 邮箱系统选择 EMAIL_PROVIDER = _cfg.get("email_provider", "kyx") # "kyx" 或 "gptmail" @@ -226,10 +330,89 @@ EMAIL_WEB_URL = _email.get("web_url", "") # GPTMail 临时邮箱配置 _gptmail = _cfg.get("gptmail", {}) GPTMAIL_API_BASE = _gptmail.get("api_base", "https://mail.chatgpt.org.uk") -GPTMAIL_API_KEY = _gptmail.get("api_key", "gpt-test") GPTMAIL_PREFIX = _gptmail.get("prefix", "") GPTMAIL_DOMAINS = _gptmail.get("domains", []) +# GPTMail API Keys 支持多个 Key 轮询 +# 兼容旧配置: api_key (单个) 和新配置: api_keys (列表) +_gptmail_api_key = _gptmail.get("api_key", "") +_gptmail_api_keys = _gptmail.get("api_keys", []) +GPTMAIL_API_KEYS = _gptmail_api_keys if _gptmail_api_keys else ([_gptmail_api_key] if _gptmail_api_key else ["gpt-test"]) + +# GPTMail Keys 文件 (用于动态管理) +GPTMAIL_KEYS_FILE = BASE_DIR / "gptmail_keys.json" +_gptmail_key_index = 0 + + +def _load_gptmail_keys() -> list: + """从文件加载 GPTMail API Keys""" + if not GPTMAIL_KEYS_FILE.exists(): + return [] + try: + with open(GPTMAIL_KEYS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("keys", []) + except Exception: + return [] + + +def _save_gptmail_keys(keys: list): + """保存 GPTMail API Keys 到文件""" + try: + with open(GPTMAIL_KEYS_FILE, "w", encoding="utf-8") as f: + json.dump({"keys": keys}, f, indent=2) + return True + except Exception: + return False + + +def get_gptmail_keys() -> list: + """获取所有 GPTMail API Keys (配置文件 + 动态添加)""" + file_keys = _load_gptmail_keys() + # 合并配置文件和动态添加的 Keys,去重 + all_keys = list(dict.fromkeys(GPTMAIL_API_KEYS + file_keys)) + return all_keys + + +def add_gptmail_key(key: str) -> bool: + """添加 GPTMail API Key""" + if not key or key.strip() == "": + return False + key = key.strip() + current_keys = _load_gptmail_keys() + if key in current_keys or key in GPTMAIL_API_KEYS: + return False # 已存在 + current_keys.append(key) + return _save_gptmail_keys(current_keys) + + +def remove_gptmail_key(key: str) -> bool: + """删除 GPTMail API Key (仅限动态添加的)""" + current_keys = _load_gptmail_keys() + if key in current_keys: + current_keys.remove(key) + return _save_gptmail_keys(current_keys) + return False + + +def get_next_gptmail_key() -> str: + """轮询获取下一个 GPTMail API Key""" + global _gptmail_key_index + keys = get_gptmail_keys() + if not keys: + return "gpt-test" + key = keys[_gptmail_key_index % len(keys)] + _gptmail_key_index += 1 + return key + + +def get_random_gptmail_key() -> str: + """随机获取一个 GPTMail API Key""" + keys = get_gptmail_keys() + if not keys: + return "gpt-test" + return random.choice(keys) + def get_random_gptmail_domain() -> str: """随机获取一个 GPTMail 可用域名 (排除黑名单)""" @@ -301,7 +484,10 @@ _domain_blacklist = _load_blacklist() AUTH_PROVIDER = _cfg.get("auth_provider") or _cfg.get("gptmail", {}).get("auth_provider", "crs") # 是否将 Team Owner 也添加到授权服务 -INCLUDE_TEAM_OWNERS = _cfg.get("include_team_owners", False) +# 注意: include_team_owners 可能在顶层或被误放在 gptmail section 下 +_include_owners_top = _cfg.get("include_team_owners") +_include_owners_gptmail = _cfg.get("gptmail", {}).get("include_team_owners") +INCLUDE_TEAM_OWNERS = _include_owners_top if _include_owners_top is not None else (_include_owners_gptmail if _include_owners_gptmail is not None else False) # CRS _crs = _cfg.get("crs", {}) @@ -377,8 +563,14 @@ TELEGRAM_CHECK_INTERVAL = _telegram.get("check_interval", 3600) # 默认1小时 TELEGRAM_LOW_STOCK_THRESHOLD = _telegram.get("low_stock_threshold", 10) # 低库存阈值 # 代理 -PROXY_ENABLED = _cfg.get("proxy_enabled", False) -PROXIES = _cfg.get("proxies", []) if PROXY_ENABLED else [] +# 注意: proxy_enabled 和 proxies 可能在顶层或被误放在 browser section 下 +_proxy_enabled_top = _cfg.get("proxy_enabled") +_proxy_enabled_browser = _cfg.get("browser", {}).get("proxy_enabled") +PROXY_ENABLED = _proxy_enabled_top if _proxy_enabled_top is not None else (_proxy_enabled_browser if _proxy_enabled_browser is not None else False) + +_proxies_top = _cfg.get("proxies") +_proxies_browser = _cfg.get("browser", {}).get("proxies") +PROXIES = (_proxies_top if _proxies_top is not None else (_proxies_browser if _proxies_browser is not None else [])) if PROXY_ENABLED else [] _proxy_index = 0 diff --git a/config.toml.example b/config.toml.example index 5721ab9..1186ad7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -8,6 +8,42 @@ # - "gptmail": GPTMail 临时邮箱系统,无需创建用户,直接生成即可收信 email_provider = "gptmail" +# ==================== 授权服务选择 ==================== +# 选择使用的授权服务: "crs" / "cpa" / "s2a" +# - crs: 原有 CRS 系统,需手动添加账号到 CRS +# - cpa: CPA (Codex/Copilot Authorization) 系统,后台自动处理账号 +# - s2a: Sub2API 系统,支持 OAuth 授权和账号入库 +auth_provider = "cpa" + +# 是否将 team.json 中的 Team Owner 也添加到授权服务 +# 开启后,运行时会自动将 team.json 中的 Owner 账号也进行授权入库 +# 注意: 请确保 Team Owner 邮箱可以接收验证码 +include_team_owners = false + +# ==================== 代理配置 ==================== +# 是否启用代理 (默认关闭) +proxy_enabled = false + +# 支持配置多个代理,程序会轮换使用 +# type: 代理类型 (socks5/http/https) +# host: 代理服务器地址 +# port: 代理端口 +# username/password: 代理认证信息 (可选) + +# [[proxies]] +# type = "socks5" +# host = "127.0.0.1" +# port = 1080 +# username = "" +# password = "" + +# [[proxies]] +# type = "http" +# host = "proxy.example.com" +# port = 8080 +# username = "user" +# password = "pass" + # ---------- Cloud Mail 邮箱系统配置 ---------- # 仅当 email_provider = "cloudmail" 时生效 # 项目地址: https://github.com/maillab/cloud-mail @@ -30,8 +66,16 @@ web_url = "https://your-email-service.com" [gptmail] # API 接口地址 api_base = "https://mail.chatgpt.org.uk" -# API 密钥 (gpt-test 为测试密钥,每日有调用限制) + +# API 密钥配置 (支持两种方式) +# 方式1: 单个 Key (兼容旧配置) api_key = "gpt-test" + +# 方式2: 多个 Key 轮询 (推荐,可分散请求限制) +# api_keys = ["key1", "key2", "key3"] + +# 注意: 也可以通过 Telegram Bot 的 /gptmail_add 命令动态添加 Key + # 邮箱前缀 (留空则自动生成 {8位随机字符}-oaiteam 格式) prefix = "" # 可用域名列表,生成邮箱时随机选择 @@ -74,18 +118,6 @@ domains = [ "zawauk.org", "zumuntahassociationuk.org" ] -# ==================== 授权服务选择 ==================== -# 选择使用的授权服务: "crs" / "cpa" / "s2a" -# - crs: 原有 CRS 系统,需手动添加账号到 CRS -# - cpa: CPA (Codex/Copilot Authorization) 系统,后台自动处理账号 -# - s2a: Sub2API 系统,支持 OAuth 授权和账号入库 -auth_provider = "cpa" - -# 是否将 team.json 中的 Team Owner 也添加到授权服务 -# 开启后,运行时会自动将 team.json 中的 Owner 账号也进行授权入库 -# 注意: 请确保 Team Owner 邮箱可以接收验证码 -include_team_owners = false - # ==================== CRS 服务配置 ==================== # CRS (Central Registration Service) 用于管理注册账号的中心服务 [crs] @@ -171,30 +203,6 @@ short_wait = 10 # 无头模式 (服务器运行时设为 true) headless = false -# ==================== 代理配置 ==================== -# 是否启用代理 (默认关闭) -proxy_enabled = false - -# 支持配置多个代理,程序会轮换使用 -# type: 代理类型 (socks5/http/https) -# host: 代理服务器地址 -# port: 代理端口 -# username/password: 代理认证信息 (可选) - -# [[proxies]] -# type = "socks5" -# host = "127.0.0.1" -# port = 1080 -# username = "" -# password = "" - -# [[proxies]] -# type = "http" -# host = "proxy.example.com" -# port = 8080 -# username = "user" -# password = "pass" - # ==================== 文件配置 ==================== [files] # 导出账号信息的 CSV 文件路径 diff --git a/email_service.py b/email_service.py index 3d4f410..4fa73d8 100644 --- a/email_service.py +++ b/email_service.py @@ -9,6 +9,7 @@ import requests from typing import Callable, TypeVar, Optional, Any from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +from concurrent.futures import ThreadPoolExecutor, as_completed from config import ( EMAIL_API_BASE, @@ -21,10 +22,11 @@ from config import ( get_random_domain, EMAIL_PROVIDER, GPTMAIL_API_BASE, - GPTMAIL_API_KEY, GPTMAIL_PREFIX, GPTMAIL_DOMAINS, get_random_gptmail_domain, + get_next_gptmail_key, + get_gptmail_keys, ) from logger import log @@ -136,27 +138,34 @@ def poll_with_retry( # ==================== GPTMail 临时邮箱服务 ==================== class GPTMailService: - """GPTMail 临时邮箱服务""" + """GPTMail 临时邮箱服务 (支持多 Key 轮询)""" def __init__(self, api_base: str = None, api_key: str = None): self.api_base = api_base or GPTMAIL_API_BASE - self.api_key = api_key or GPTMAIL_API_KEY - self.headers = { - "X-API-Key": self.api_key, + # 如果指定了 api_key 则使用指定的,否则使用轮询 + self._fixed_key = api_key + + def _get_headers(self, api_key: str = None) -> dict: + """获取请求头 (支持指定 Key 或轮询)""" + key = api_key or self._fixed_key or get_next_gptmail_key() + return { + "X-API-Key": key, "Content-Type": "application/json" } - def generate_email(self, prefix: str = None, domain: str = None) -> tuple[str, str]: + def generate_email(self, prefix: str = None, domain: str = None, api_key: str = None) -> tuple[str, str]: """生成临时邮箱地址 Args: prefix: 邮箱前缀 (可选) domain: 域名 (可选) + api_key: 指定使用的 API Key (可选,不指定则轮询) Returns: tuple: (email, error) - 邮箱地址和错误信息 """ url = f"{self.api_base}/api/generate-email" + headers = self._get_headers(api_key) try: if prefix or domain: @@ -165,9 +174,9 @@ class GPTMailService: payload["prefix"] = prefix if domain: payload["domain"] = domain - response = http_session.post(url, headers=self.headers, json=payload, timeout=REQUEST_TIMEOUT) + response = http_session.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT) else: - response = http_session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT) + response = http_session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) data = response.json() @@ -195,9 +204,10 @@ class GPTMailService: """ url = f"{self.api_base}/api/emails" params = {"email": email} + headers = self._get_headers() try: - response = http_session.get(url, headers=self.headers, params=params, timeout=REQUEST_TIMEOUT) + response = http_session.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT) data = response.json() if data.get("success"): @@ -221,9 +231,10 @@ class GPTMailService: tuple: (email_detail, error) - 邮件详情和错误信息 """ url = f"{self.api_base}/api/email/{email_id}" + headers = self._get_headers() try: - response = http_session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT) + response = http_session.get(url, headers=headers, timeout=REQUEST_TIMEOUT) data = response.json() if data.get("success"): @@ -246,9 +257,10 @@ class GPTMailService: tuple: (success, error) """ url = f"{self.api_base}/api/email/{email_id}" + headers = self._get_headers() try: - response = http_session.delete(url, headers=self.headers, timeout=REQUEST_TIMEOUT) + response = http_session.delete(url, headers=headers, timeout=REQUEST_TIMEOUT) data = response.json() if data.get("success"): @@ -270,9 +282,10 @@ class GPTMailService: """ url = f"{self.api_base}/api/emails/clear" params = {"email": email} + headers = self._get_headers() try: - response = http_session.delete(url, headers=self.headers, params=params, timeout=REQUEST_TIMEOUT) + response = http_session.delete(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT) data = response.json() if data.get("success"): @@ -284,6 +297,35 @@ class GPTMailService: except Exception as e: return 0, str(e) + def test_api_key(self, api_key: str) -> tuple[bool, str]: + """测试 API Key 是否有效 + + Args: + api_key: 要测试的 API Key + + Returns: + tuple: (success, message) + """ + url = f"{self.api_base}/api/generate-email" + headers = { + "X-API-Key": api_key, + "Content-Type": "application/json" + } + + try: + response = http_session.get(url, headers=headers, timeout=10) + data = response.json() + + if data.get("success"): + email = data.get("data", {}).get("email", "") + return True, f"Key 有效,测试邮箱: {email}" + else: + error = data.get("error", "Unknown error") + return False, f"Key 无效: {error}" + + except Exception as e: + return False, f"测试失败: {e}" + def get_verification_code(self, email: str, max_retries: int = None, interval: int = None) -> tuple[str, str, str]: """从邮箱获取验证码 (使用通用轮询重试) @@ -532,29 +574,42 @@ def fetch_email_content(email: str) -> list: return [] -def batch_create_emails(count: int = 4) -> list: - """批量创建邮箱 (根据 EMAIL_PROVIDER 配置自动选择邮箱系统) +def batch_create_emails(count: int = 4, max_workers: int = 4) -> list: + """批量创建邮箱 (并行版本,根据 EMAIL_PROVIDER 配置自动选择邮箱系统) Args: count: 创建数量 + max_workers: 最大并行线程数,默认 4 Returns: list: [{"email": "...", "password": "..."}, ...] """ accounts = [] + failed_count = 0 - for i in range(count): - email, password = unified_create_email() + # 使用线程池并行创建邮箱 + with ThreadPoolExecutor(max_workers=min(count, max_workers)) as executor: + # 提交所有创建任务 + futures = {executor.submit(unified_create_email): i for i in range(count)} - if email: - accounts.append({ - "email": email, - "password": password - }) - else: - log.warning(f"跳过第 {i+1} 个邮箱创建") + # 收集结果 + for future in as_completed(futures): + task_idx = futures[future] + try: + email, password = future.result() + if email: + accounts.append({ + "email": email, + "password": password + }) + else: + failed_count += 1 + log.warning(f"邮箱创建失败 (任务 {task_idx + 1})") + except Exception as e: + failed_count += 1 + log.warning(f"邮箱创建异常 (任务 {task_idx + 1}): {e}") - log.info(f"邮箱创建完成: {len(accounts)}/{count}", icon="email") + log.info(f"邮箱创建完成: {len(accounts)}/{count}" + (f" (失败 {failed_count})" if failed_count else ""), icon="email") return accounts diff --git a/install_service.sh b/install_service.sh new file mode 100644 index 0000000..c1dffb8 --- /dev/null +++ b/install_service.sh @@ -0,0 +1,196 @@ +#!/bin/bash +# ==================== Telegram Bot Systemd 服务安装脚本 ==================== +# 用法: sudo bash install_service.sh [install|uninstall|status|logs] + +set -e + +# ==================== 配置 ==================== +SERVICE_NAME="oai-team-bot" +SERVICE_DESC="OpenAI Team Provisioner Bot" +WORK_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON_BIN="${WORK_DIR}/.venv/bin/python" +SCRIPT_NAME="telegram_bot.py" +USER="${SUDO_USER:-root}" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ==================== 检查环境 ==================== +check_env() { + # 检查是否为 root + if [ "$EUID" -ne 0 ]; then + log_error "请使用 sudo 运行此脚本" + exit 1 + fi + + # 检查工作目录 + if [ ! -f "${WORK_DIR}/${SCRIPT_NAME}" ]; then + log_error "找不到 ${SCRIPT_NAME},请在项目目录下运行此脚本" + exit 1 + fi + + # 检查 Python 环境 + if [ ! -f "$PYTHON_BIN" ]; then + log_warn "未找到 .venv,尝试使用系统 Python" + PYTHON_BIN=$(which python3 || which python) + if [ -z "$PYTHON_BIN" ]; then + log_error "找不到 Python" + exit 1 + fi + fi + + log_info "工作目录: ${WORK_DIR}" + log_info "Python: ${PYTHON_BIN}" + log_info "运行用户: ${USER}" +} + +# ==================== 安装服务 ==================== +install_service() { + log_info "正在安装 ${SERVICE_NAME} 服务..." + + # 创建 systemd service 文件 + cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF +[Unit] +Description=${SERVICE_DESC} +After=network.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=${USER} +Group=${USER} +WorkingDirectory=${WORK_DIR} +ExecStart=${PYTHON_BIN} ${WORK_DIR}/${SCRIPT_NAME} +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# 环境变量 +Environment="PYTHONUNBUFFERED=1" +Environment="LOG_LEVEL=INFO" + +# 安全限制 +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=${WORK_DIR} + +[Install] +WantedBy=multi-user.target +EOF + + log_success "服务文件已创建: /etc/systemd/system/${SERVICE_NAME}.service" + + # 重新加载 systemd + systemctl daemon-reload + log_success "systemd 配置已重载" + + # 启用开机自启 + systemctl enable ${SERVICE_NAME} + log_success "已启用开机自启" + + # 启动服务 + systemctl start ${SERVICE_NAME} + log_success "服务已启动" + + # 显示状态 + echo "" + systemctl status ${SERVICE_NAME} --no-pager + + echo "" + log_success "安装完成!" + echo "" + echo "常用命令:" + echo " 查看状态: sudo systemctl status ${SERVICE_NAME}" + echo " 查看日志: sudo journalctl -u ${SERVICE_NAME} -f" + echo " 重启服务: sudo systemctl restart ${SERVICE_NAME}" + echo " 停止服务: sudo systemctl stop ${SERVICE_NAME}" + echo " 卸载服务: sudo bash $0 uninstall" +} + +# ==================== 卸载服务 ==================== +uninstall_service() { + log_info "正在卸载 ${SERVICE_NAME} 服务..." + + # 停止服务 + systemctl stop ${SERVICE_NAME} 2>/dev/null || true + log_info "服务已停止" + + # 禁用开机自启 + systemctl disable ${SERVICE_NAME} 2>/dev/null || true + log_info "已禁用开机自启" + + # 删除服务文件 + rm -f /etc/systemd/system/${SERVICE_NAME}.service + log_info "服务文件已删除" + + # 重新加载 systemd + systemctl daemon-reload + log_success "卸载完成!" +} + +# ==================== 查看状态 ==================== +show_status() { + systemctl status ${SERVICE_NAME} --no-pager +} + +# ==================== 查看日志 ==================== +show_logs() { + journalctl -u ${SERVICE_NAME} -f --no-pager -n 50 +} + +# ==================== 主函数 ==================== +main() { + case "${1:-install}" in + install) + check_env + install_service + ;; + uninstall|remove) + uninstall_service + ;; + status) + show_status + ;; + logs|log) + show_logs + ;; + restart) + systemctl restart ${SERVICE_NAME} + log_success "服务已重启" + show_status + ;; + stop) + systemctl stop ${SERVICE_NAME} + log_success "服务已停止" + ;; + start) + systemctl start ${SERVICE_NAME} + log_success "服务已启动" + show_status + ;; + *) + echo "用法: sudo bash $0 [命令]" + echo "" + echo "命令:" + echo " install 安装并启动服务 (默认)" + echo " uninstall 卸载服务" + echo " status 查看服务状态" + echo " logs 查看实时日志" + echo " restart 重启服务" + echo " start 启动服务" + echo " stop 停止服务" + ;; + esac +} + +main "$@" diff --git a/telegram_bot.py b/telegram_bot.py index f03fb94..e304691 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor from functools import wraps from typing import Optional -from telegram import Update, Bot +from telegram import Update, Bot, BotCommand from telegram.ext import ( Application, CommandHandler, @@ -34,10 +34,22 @@ from config import ( S2A_API_BASE, CPA_API_BASE, CRS_API_BASE, + get_gptmail_keys, + add_gptmail_key, + remove_gptmail_key, + GPTMAIL_API_KEYS, + INCLUDE_TEAM_OWNERS, + reload_config, + S2A_CONCURRENCY, + S2A_PRIORITY, + S2A_GROUP_NAMES, + S2A_GROUP_IDS, + S2A_ADMIN_KEY, ) from utils import load_team_tracker from bot_notifier import BotNotifier, set_notifier, progress_finish from s2a_service import s2a_get_dashboard_stats, format_dashboard_stats +from email_service import gptmail_service, unified_create_email from logger import log @@ -93,6 +105,13 @@ class ProvisionerBot: ("dashboard", self.cmd_dashboard), ("import", self.cmd_import), ("stock", self.cmd_stock), + ("gptmail_keys", self.cmd_gptmail_keys), + ("gptmail_add", self.cmd_gptmail_add), + ("gptmail_del", self.cmd_gptmail_del), + ("test_email", self.cmd_test_email), + ("include_owners", self.cmd_include_owners), + ("reload", self.cmd_reload), + ("s2a_config", self.cmd_s2a_config), ] for cmd, handler in handlers: self.app.add_handler(CommandHandler(cmd, handler)) @@ -125,6 +144,10 @@ class ProvisionerBot: # 运行 Bot await self.app.initialize() await self.app.start() + + # 设置命令菜单提示 + await self._set_commands() + await self.app.updater.start_polling(drop_pending_updates=True) # 等待关闭信号 @@ -140,6 +163,36 @@ class ProvisionerBot: """请求关闭 Bot""" self._shutdown_event.set() + async def _set_commands(self): + """设置 Bot 命令菜单提示""" + commands = [ + BotCommand("help", "查看帮助信息"), + BotCommand("list", "查看 team.json 账号列表"), + BotCommand("status", "查看任务处理状态"), + BotCommand("team", "查看指定 Team 详情"), + BotCommand("config", "查看系统配置"), + BotCommand("run", "处理指定 Team"), + BotCommand("run_all", "处理所有 Team"), + BotCommand("stop", "停止当前任务"), + BotCommand("logs", "查看最近日志"), + BotCommand("headless", "切换无头模式"), + BotCommand("dashboard", "查看 S2A 仪表盘"), + BotCommand("stock", "查看账号库存"), + BotCommand("import", "导入账号到 team.json"), + BotCommand("gptmail_keys", "查看 GPTMail API Keys"), + BotCommand("gptmail_add", "添加 GPTMail API Key"), + BotCommand("gptmail_del", "删除 GPTMail API Key"), + BotCommand("test_email", "测试邮箱创建功能"), + BotCommand("include_owners", "切换 Owner 入库开关"), + BotCommand("reload", "重载配置文件"), + BotCommand("s2a_config", "配置 S2A 服务参数"), + ] + try: + await self.app.bot.set_my_commands(commands) + log.info("Bot 命令菜单已设置") + except Exception as e: + log.warning(f"设置命令菜单失败: {e}") + @admin_only async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """显示帮助信息""" @@ -159,19 +212,28 @@ class ProvisionerBot: ⚙️ 配置管理: /headless - 开启/关闭无头模式 +/include_owners - 开启/关闭 Owner 入库 +/reload - 重载配置文件 (无需重启) 📊 S2A 专属: /dashboard - 查看 S2A 仪表盘 /stock - 查看账号库存 +/s2a_config - 配置 S2A 参数 📤 导入账号: /import - 导入账号到 team.json 或直接发送 JSON 文件 +📧 GPTMail 管理: +/gptmail_keys - 查看所有 API Keys +/gptmail_add <key> - 添加 API Key +/gptmail_del <key> - 删除 API Key +/test_email - 测试邮箱创建 + 💡 示例: /list - 查看所有待处理账号 /run 0 - 处理第一个 Team -/config - 查看当前配置""" +/gptmail_add my-api-key - 添加 Key""" await update.message.reply_text(help_text, parse_mode="HTML") @admin_only @@ -300,6 +362,9 @@ class ProvisionerBot: # 无头模式状态 headless_status = "✅ 已开启" if BROWSER_HEADLESS else "❌ 未开启" + # Owner 入库状态 + include_owners_status = "✅ 已开启" if INCLUDE_TEAM_OWNERS else "❌ 未开启" + lines = [ "⚙️ 系统配置", "", @@ -309,6 +374,7 @@ class ProvisionerBot: "🔐 授权服务", f" 模式: {AUTH_PROVIDER.upper()}", f" 地址: {auth_url}", + f" Owner 入库: {include_owners_status}", "", "🌐 浏览器", f" 无头模式: {headless_status}", @@ -321,7 +387,8 @@ class ProvisionerBot: f" 状态: {proxy_info}", "", "💡 提示:", - "使用 /headless 开启/关闭无头模式", + "/headless - 切换无头模式", + "/include_owners - 切换 Owner 入库", ] await update.message.reply_text("\n".join(lines), parse_mode="HTML") @@ -354,7 +421,7 @@ class ProvisionerBot: await update.message.reply_text( f"🌐 无头模式\n\n" f"状态: {status}\n\n" - f"⚠️ 需要重启 Bot 生效", + f"💡 使用 /reload 立即生效", parse_mode="HTML" ) @@ -366,6 +433,246 @@ class ProvisionerBot: except Exception as e: await update.message.reply_text(f"❌ 修改配置失败: {e}") + @admin_only + async def cmd_include_owners(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """切换 Owner 入库开关""" + import tomli_w + + try: + # 读取当前配置 + with open(CONFIG_FILE, "rb") as f: + import tomllib + config = tomllib.load(f) + + # 获取当前状态 (顶层配置) + current = config.get("include_team_owners", False) + new_value = not current + + # 更新配置 (写到顶层) + config["include_team_owners"] = new_value + + # 写回文件 + with open(CONFIG_FILE, "wb") as f: + tomli_w.dump(config, f) + + status = "✅ 已开启" if new_value else "❌ 已关闭" + desc = "运行任务时会将 Team Owner 账号也入库到授权服务" if new_value else "运行任务时不会入库 Team Owner 账号" + + await update.message.reply_text( + f"👤 Owner 入库开关\n\n" + f"状态: {status}\n" + f"{desc}\n\n" + f"💡 使用 /reload 立即生效", + parse_mode="HTML" + ) + + except ImportError: + await update.message.reply_text( + "❌ 缺少 tomli_w 依赖\n" + "请运行: uv add tomli_w" + ) + except Exception as e: + await update.message.reply_text(f"❌ 修改配置失败: {e}") + + @admin_only + async def cmd_reload(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """重载配置文件""" + # 检查是否有任务正在运行 + if self.current_task and not self.current_task.done(): + await update.message.reply_text( + f"⚠️ 有任务正在运行: {self.current_team}\n" + "请等待任务完成或使用 /stop 停止后再重载配置" + ) + return + + await update.message.reply_text("⏳ 正在重载配置...") + + try: + # 调用重载函数 + result = reload_config() + + if result["success"]: + # 重新导入更新后的配置变量 + from config import ( + EMAIL_PROVIDER as new_email_provider, + AUTH_PROVIDER as new_auth_provider, + INCLUDE_TEAM_OWNERS as new_include_owners, + BROWSER_HEADLESS as new_headless, + ACCOUNTS_PER_TEAM as new_accounts_per_team, + ) + + lines = [ + "✅ 配置重载成功", + "", + f"📁 team.json: {result['teams_count']} 个账号", + f"📄 config.toml: {'已更新' if result['config_updated'] else '未变化'}", + "", + "当前配置:", + f" 邮箱服务: {new_email_provider}", + f" 授权服务: {new_auth_provider}", + f" Owner 入库: {'✅' if new_include_owners else '❌'}", + f" 无头模式: {'✅' if new_headless else '❌'}", + f" 每 Team 账号: {new_accounts_per_team}", + ] + + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + else: + await update.message.reply_text( + f"❌ 配置重载失败\n\n{result['message']}", + parse_mode="HTML" + ) + + except Exception as e: + await update.message.reply_text(f"❌ 重载配置失败: {e}") + + @admin_only + async def cmd_s2a_config(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """配置 S2A 服务参数""" + import tomli_w + + # 无参数时显示当前配置 + if not context.args: + # 脱敏显示 API Key + key_display = "未配置" + if S2A_ADMIN_KEY: + if len(S2A_ADMIN_KEY) > 10: + key_display = f"{S2A_ADMIN_KEY[:4]}...{S2A_ADMIN_KEY[-4:]}" + else: + key_display = S2A_ADMIN_KEY[:4] + "..." + + groups_display = ", ".join(S2A_GROUP_NAMES) if S2A_GROUP_NAMES else "默认分组" + group_ids_display = ", ".join(S2A_GROUP_IDS) if S2A_GROUP_IDS else "无" + + lines = [ + "📊 S2A 服务配置", + "", + f"API 地址: {S2A_API_BASE or '未配置'}", + f"Admin Key: {key_display}", + f"并发数: {S2A_CONCURRENCY}", + f"优先级: {S2A_PRIORITY}", + f"分组名称: {groups_display}", + f"分组 ID: {group_ids_display}", + "", + "💡 修改配置:", + "/s2a_config concurrency 10", + "/s2a_config priority 50", + "/s2a_config groups 分组1,分组2", + "/s2a_config api_base https://...", + "/s2a_config admin_key sk-xxx", + ] + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + return + + # 解析参数 + param = context.args[0].lower() + value = " ".join(context.args[1:]) if len(context.args) > 1 else None + + if not value: + await update.message.reply_text( + f"❌ 缺少值\n用法: /s2a_config {param} <值>" + ) + return + + try: + # 读取当前配置 + with open(CONFIG_FILE, "rb") as f: + import tomllib + config = tomllib.load(f) + + # 确保 s2a section 存在 + if "s2a" not in config: + config["s2a"] = {} + + # 根据参数类型处理 + updated_key = None + updated_value = None + + if param == "concurrency": + try: + new_val = int(value) + if new_val < 1 or new_val > 100: + await update.message.reply_text("❌ 并发数范围: 1-100") + return + config["s2a"]["concurrency"] = new_val + updated_key = "并发数" + updated_value = str(new_val) + except ValueError: + await update.message.reply_text("❌ 并发数必须是数字") + return + + elif param == "priority": + try: + new_val = int(value) + if new_val < 0 or new_val > 100: + await update.message.reply_text("❌ 优先级范围: 0-100") + return + config["s2a"]["priority"] = new_val + updated_key = "优先级" + updated_value = str(new_val) + except ValueError: + await update.message.reply_text("❌ 优先级必须是数字") + return + + elif param in ("groups", "group_names"): + # 支持逗号分隔的分组名称 + groups = [g.strip() for g in value.split(",") if g.strip()] + config["s2a"]["group_names"] = groups + updated_key = "分组名称" + updated_value = ", ".join(groups) if groups else "默认分组" + + elif param == "group_ids": + # 支持逗号分隔的分组 ID + ids = [i.strip() for i in value.split(",") if i.strip()] + config["s2a"]["group_ids"] = ids + updated_key = "分组 ID" + updated_value = ", ".join(ids) if ids else "无" + + elif param == "api_base": + config["s2a"]["api_base"] = value + updated_key = "API 地址" + updated_value = value + + elif param == "admin_key": + config["s2a"]["admin_key"] = value + updated_key = "Admin Key" + # 脱敏显示 + if len(value) > 10: + updated_value = f"{value[:4]}...{value[-4:]}" + else: + updated_value = value[:4] + "..." + + else: + await update.message.reply_text( + f"❌ 未知参数: {param}\n\n" + "可用参数:\n" + "• concurrency - 并发数\n" + "• priority - 优先级\n" + "• groups - 分组名称\n" + "• group_ids - 分组 ID\n" + "• api_base - API 地址\n" + "• admin_key - Admin Key" + ) + return + + # 写回文件 + with open(CONFIG_FILE, "wb") as f: + tomli_w.dump(config, f) + + await update.message.reply_text( + f"✅ S2A 配置已更新\n\n" + f"{updated_key}: {updated_value}\n\n" + f"💡 使用 /reload 立即生效", + parse_mode="HTML" + ) + + except ImportError: + await update.message.reply_text( + "❌ 缺少 tomli_w 依赖\n" + "请运行: uv add tomli_w" + ) + except Exception as e: + await update.message.reply_text(f"❌ 修改配置失败: {e}") + @admin_only async def cmd_run(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """启动处理指定 Team""" @@ -452,24 +759,56 @@ class ProvisionerBot: @admin_only async def cmd_stop(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - """停止当前任务""" + """强制停止当前任务""" if not self.current_task or self.current_task.done(): await update.message.reply_text("📭 当前没有运行中的任务") return - # 注意: 由于任务在线程池中运行,无法直接取消 - # 这里只能发送信号 - await update.message.reply_text( - f"🛑 正在停止: {self.current_team}\n" - "注意: 当前账号处理完成后才会停止" - ) + task_name = self.current_team or "未知任务" + await update.message.reply_text(f"🛑 正在强制停止: {task_name}...") - # 设置全局停止标志 try: - import run - run._shutdown_requested = True - except Exception: - pass + # 1. 设置全局停止标志 + try: + import run + run._shutdown_requested = True + except Exception: + pass + + # 2. 取消 asyncio 任务 + if self.current_task and not self.current_task.done(): + self.current_task.cancel() + + # 3. 强制关闭浏览器进程 + try: + from browser_automation import cleanup_chrome_processes + cleanup_chrome_processes() + except Exception as e: + log.warning(f"清理浏览器进程失败: {e}") + + # 4. 重置状态 + self.current_team = None + + # 5. 重置停止标志 (以便下次任务可以正常运行) + try: + import run + run._shutdown_requested = False + except Exception: + pass + + # 清理进度跟踪 + progress_finish() + + await update.message.reply_text( + f"✅ 任务已强制停止\n\n" + f"已停止: {task_name}\n" + f"已清理浏览器进程\n\n" + f"使用 /status 查看状态", + parse_mode="HTML" + ) + + except Exception as e: + await update.message.reply_text(f"❌ 停止任务时出错: {e}") @admin_only async def cmd_logs(self, update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -711,10 +1050,12 @@ class ProvisionerBot: await update.message.reply_text("❌ 未找到有效账号 (需要 account/email 和 token 字段)") return - # 读取现有 team.json + # 读取现有 team.json (不存在则自动创建) team_json_path = Path(TEAM_JSON_FILE) existing_accounts = [] - if team_json_path.exists(): + is_new_file = not team_json_path.exists() + + if not is_new_file: try: with open(team_json_path, "r", encoding="utf-8") as f: existing_accounts = json.load(f) @@ -741,16 +1082,23 @@ class ProvisionerBot: existing_emails.add(email) added += 1 - # 保存到 team.json + # 保存到 team.json (自动创建文件) try: + # 确保父目录存在 + team_json_path.parent.mkdir(parents=True, exist_ok=True) + with open(team_json_path, "w", encoding="utf-8") as f: json.dump(existing_accounts, f, ensure_ascii=False, indent=2) + file_status = "📄 已创建 team.json" if is_new_file else "📄 已更新 team.json" + await update.message.reply_text( f"✅ 导入完成\n\n" + f"{file_status}\n" f"新增: {added}\n" f"跳过 (重复): {skipped}\n" f"team.json 总数: {len(existing_accounts)}\n\n" + f"💡 使用 /reload 刷新配置\n" f"使用 /run_all 或 /run <n> 开始处理", parse_mode="HTML" ) @@ -758,6 +1106,190 @@ class ProvisionerBot: except Exception as e: await update.message.reply_text(f"❌ 保存到 team.json 失败: {e}") + # ==================== GPTMail Key 管理命令 ==================== + + @admin_only + async def cmd_gptmail_keys(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """查看所有 GPTMail API Keys""" + keys = get_gptmail_keys() + config_keys = set(GPTMAIL_API_KEYS) + + if not keys: + await update.message.reply_text( + "📧 GPTMail API Keys\n\n" + "📭 暂无配置 Key\n\n" + "使用 /gptmail_add <key> 添加", + parse_mode="HTML" + ) + return + + lines = [f"📧 GPTMail API Keys (共 {len(keys)} 个)\n"] + + for i, key in enumerate(keys): + # 脱敏显示 + if len(key) > 10: + masked = f"{key[:4]}...{key[-4:]}" + else: + masked = key[:4] + "..." if len(key) > 4 else key + + # 标记来源 + source = "📁 配置" if key in config_keys else "🔧 动态" + lines.append(f"{i+1}. {masked} {source}") + + lines.append(f"\n💡 管理:") + lines.append(f"/gptmail_add <key> - 添加 Key") + lines.append(f"/gptmail_del <key> - 删除动态 Key") + lines.append(f"/test_email - 测试邮箱创建") + + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + + @admin_only + async def cmd_gptmail_add(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """添加 GPTMail API Key (支持批量导入)""" + if not context.args: + await update.message.reply_text( + "📧 添加 GPTMail API Key\n\n" + "单个添加:\n" + "/gptmail_add gpt-xxx\n\n" + "批量添加 (空格分隔):\n" + "/gptmail_add key1 key2 key3\n\n" + "批量添加 (换行分隔):\n" + "/gptmail_add key1\nkey2\nkey3", + parse_mode="HTML" + ) + return + + # 合并所有参数,支持空格和换行分隔 + raw_input = " ".join(context.args) + # 按空格和换行分割 + keys = [] + for part in raw_input.replace("\n", " ").split(): + key = part.strip() + if key: + keys.append(key) + + if not keys: + await update.message.reply_text("❌ Key 不能为空") + return + + # 获取现有 keys + existing_keys = set(get_gptmail_keys()) + + # 统计结果 + added = [] + skipped = [] + invalid = [] + + await update.message.reply_text(f"⏳ 正在验证 {len(keys)} 个 Key...") + + for key in keys: + # 检查是否已存在 + if key in existing_keys: + skipped.append(key) + continue + + # 测试 Key 是否有效 + success, message = gptmail_service.test_api_key(key) + + if not success: + invalid.append(key) + continue + + # 添加 Key + if add_gptmail_key(key): + added.append(key) + existing_keys.add(key) + + # 生成结果报告 + lines = ["📧 GPTMail Key 导入结果\n"] + + if added: + lines.append(f"✅ 成功添加: {len(added)}") + for k in added[:5]: # 最多显示5个 + masked = f"{k[:4]}...{k[-4:]}" if len(k) > 10 else k + lines.append(f" • {masked}") + if len(added) > 5: + lines.append(f" • ... 等 {len(added)} 个") + + if skipped: + lines.append(f"\n⏭️ 已跳过 (已存在): {len(skipped)}") + + if invalid: + lines.append(f"\n❌ 无效 Key: {len(invalid)}") + for k in invalid[:3]: # 最多显示3个 + masked = f"{k[:4]}...{k[-4:]}" if len(k) > 10 else k + lines.append(f" • {masked}") + + lines.append(f"\n当前 Key 总数: {len(get_gptmail_keys())}") + + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + + @admin_only + async def cmd_gptmail_del(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """删除 GPTMail API Key""" + if not context.args: + await update.message.reply_text( + "📧 删除 GPTMail API Key\n\n" + "用法: /gptmail_del <key>\n\n" + "注意: 只能删除动态添加的 Key,配置文件中的 Key 请直接修改 config.toml", + parse_mode="HTML" + ) + return + + key = context.args[0].strip() + + # 检查是否是配置文件中的 Key + if key in GPTMAIL_API_KEYS: + await update.message.reply_text( + "⚠️ 该 Key 在配置文件中,无法通过 Bot 删除\n" + "请直接修改 config.toml" + ) + return + + # 删除 Key + if remove_gptmail_key(key): + await update.message.reply_text( + f"✅ Key 已删除\n\n" + f"当前 Key 总数: {len(get_gptmail_keys())}", + parse_mode="HTML" + ) + else: + await update.message.reply_text("❌ Key 不存在或删除失败") + + @admin_only + async def cmd_test_email(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """测试邮箱创建功能""" + if EMAIL_PROVIDER != "gptmail": + await update.message.reply_text( + f"⚠️ 当前邮箱提供商: {EMAIL_PROVIDER}\n" + f"测试功能仅支持 GPTMail 模式" + ) + return + + await update.message.reply_text("⏳ 正在测试邮箱创建...") + + try: + # 测试创建邮箱 + email, password = unified_create_email() + + if email: + await update.message.reply_text( + f"✅ 邮箱创建成功\n\n" + f"邮箱: {email}\n" + f"密码: {password}\n\n" + f"当前 Key 数量: {len(get_gptmail_keys())}", + parse_mode="HTML" + ) + else: + await update.message.reply_text( + "❌ 邮箱创建失败\n\n" + "请检查 GPTMail API Key 配置", + parse_mode="HTML" + ) + + except Exception as e: + await update.message.reply_text(f"❌ 测试失败: {e}") + async def main(): """主函数"""