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():
"""主函数"""