|
4 | 4 |
|
5 | 5 | import os |
6 | 6 | import re |
| 7 | +from pathlib import Path |
7 | 8 | from typing import Optional, Dict |
| 9 | +from urllib.parse import urlparse |
8 | 10 | from loguru import logger |
9 | 11 |
|
10 | 12 |
|
@@ -33,6 +35,65 @@ def __init__(self): |
33 | 35 | # 数据目录(可选) |
34 | 36 | self.DATA_DIR = os.getenv("DATA_DIR", "").strip() |
35 | 37 |
|
| 38 | + # MatchScope HTTP ingress (disabled by default for seamless upgrades). |
| 39 | + self.MATCHSCOPE_PRIVATE_API_ENABLED = self._parse_bool_env( |
| 40 | + "MATCHSCOPE_PRIVATE_API_ENABLED", False |
| 41 | + ) |
| 42 | + self.MATCHSCOPE_PRIVATE_API_HOST = os.getenv( |
| 43 | + "MATCHSCOPE_PRIVATE_API_HOST", "0.0.0.0" |
| 44 | + ).strip() |
| 45 | + self.MATCHSCOPE_PRIVATE_API_PORT = self._parse_int_env( |
| 46 | + "MATCHSCOPE_PRIVATE_API_PORT", 8765, min_value=1, max_value=65535 |
| 47 | + ) |
| 48 | + self.MATCHSCOPE_PRIVATE_API_PATH = self._parse_api_path( |
| 49 | + "MATCHSCOPE_PRIVATE_API_PATH" |
| 50 | + ) |
| 51 | + self.MATCHSCOPE_PRIVATE_API_TOKEN = self._read_secret( |
| 52 | + "MATCHSCOPE_PRIVATE_API_TOKEN" |
| 53 | + ) |
| 54 | + self.MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR = self._parse_int_env( |
| 55 | + "MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR", 1000, min_value=1 |
| 56 | + ) |
| 57 | + self.MATCHSCOPE_PRIVATE_API_TLS_CERT_FILE = os.getenv( |
| 58 | + "MATCHSCOPE_PRIVATE_API_TLS_CERT_FILE", "" |
| 59 | + ).strip() |
| 60 | + self.MATCHSCOPE_PRIVATE_API_TLS_KEY_FILE = os.getenv( |
| 61 | + "MATCHSCOPE_PRIVATE_API_TLS_KEY_FILE", "" |
| 62 | + ).strip() |
| 63 | + |
| 64 | + self.MATCHSCOPE_PUBLIC_API_ENABLED = self._parse_bool_env( |
| 65 | + "MATCHSCOPE_PUBLIC_API_ENABLED", False |
| 66 | + ) |
| 67 | + self.MATCHSCOPE_PUBLIC_API_HOST = os.getenv( |
| 68 | + "MATCHSCOPE_PUBLIC_API_HOST", "0.0.0.0" |
| 69 | + ).strip() |
| 70 | + self.MATCHSCOPE_PUBLIC_API_PORT = self._parse_int_env( |
| 71 | + "MATCHSCOPE_PUBLIC_API_PORT", 7654, min_value=1, max_value=65535 |
| 72 | + ) |
| 73 | + self.MATCHSCOPE_PUBLIC_API_PATH = self._parse_api_path( |
| 74 | + "MATCHSCOPE_PUBLIC_API_PATH" |
| 75 | + ) |
| 76 | + self.MATCHSCOPE_PUBLIC_BASE_URL = os.getenv( |
| 77 | + "MATCHSCOPE_PUBLIC_BASE_URL", "" |
| 78 | + ).strip().rstrip("/") |
| 79 | + self.MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR = self._parse_int_env( |
| 80 | + "MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR", 50, min_value=1 |
| 81 | + ) |
| 82 | + self.MATCHSCOPE_PUBLIC_API_TLS_CERT_FILE = os.getenv( |
| 83 | + "MATCHSCOPE_PUBLIC_API_TLS_CERT_FILE", "" |
| 84 | + ).strip() |
| 85 | + self.MATCHSCOPE_PUBLIC_API_TLS_KEY_FILE = os.getenv( |
| 86 | + "MATCHSCOPE_PUBLIC_API_TLS_KEY_FILE", "" |
| 87 | + ).strip() |
| 88 | + self.MATCHSCOPE_TOKEN_SIGNING_KEY = self._read_secret( |
| 89 | + "MATCHSCOPE_TOKEN_SIGNING_KEY" |
| 90 | + ) |
| 91 | + self.MATCHSCOPE_TOKEN_TTL_DAYS = self._parse_int_env( |
| 92 | + "MATCHSCOPE_TOKEN_TTL_DAYS", 90, min_value=1, max_value=365 |
| 93 | + ) |
| 94 | + token_database = os.getenv("MATCHSCOPE_TOKEN_DATABASE", "").strip() |
| 95 | + self.MATCHSCOPE_TOKEN_DATABASE = Path(token_database) if token_database else None |
| 96 | + |
36 | 97 | # 性能与缓存配置 |
37 | 98 | self.DNS_CACHE_TTL = self._parse_int_env("DNS_CACHE_TTL", 60, min_value=0) |
38 | 99 | self.DNS_CACHE_SIZE = self._parse_int_env("DNS_CACHE_SIZE", 1024, min_value=0) |
@@ -64,6 +125,8 @@ def __init__(self): |
64 | 125 | logger.warning(f"无效的 REQUIRED_GROUP_ID: {required_group_id_raw}") |
65 | 126 | if self.REQUIRED_GROUP_ID and not self.GROUP_CHECK_ENABLED: |
66 | 127 | logger.warning("群组验证已关闭:REQUIRED_GROUP_NAME 或 REQUIRED_GROUP_LINK 未配置") |
| 128 | + |
| 129 | + self._validate_matchscope_config() |
67 | 130 |
|
68 | 131 | # 群组工作模式配置(允许机器人在这些群组中直接响应 @提及) |
69 | 132 | # 支持逗号分隔的多个群组 ID,例如:-1001234567890,-1009876543210 |
@@ -134,6 +197,74 @@ def _get_env_required(self, key: str) -> str: |
134 | 197 | raise ValueError(f"Required environment variable {key} is not set") |
135 | 198 | return value |
136 | 199 |
|
| 200 | + def _read_secret(self, key: str) -> str: |
| 201 | + """Read a secret from KEY or KEY_FILE without logging its value.""" |
| 202 | + value = os.getenv(key, "").strip() |
| 203 | + file_path = os.getenv(f"{key}_FILE", "").strip() |
| 204 | + if value and file_path: |
| 205 | + raise ValueError(f"{key} and {key}_FILE cannot both be set") |
| 206 | + if not file_path: |
| 207 | + return value |
| 208 | + try: |
| 209 | + return Path(file_path).read_text(encoding="utf-8").strip() |
| 210 | + except OSError as error: |
| 211 | + raise ValueError(f"Unable to read {key}_FILE") from error |
| 212 | + |
| 213 | + def _parse_bool_env(self, key: str, default: bool) -> bool: |
| 214 | + raw = os.getenv(key, "").strip().lower() |
| 215 | + if not raw: |
| 216 | + return default |
| 217 | + if raw in {"1", "true", "yes", "on"}: |
| 218 | + return True |
| 219 | + if raw in {"0", "false", "no", "off"}: |
| 220 | + return False |
| 221 | + raise ValueError(f"Invalid boolean value for {key}") |
| 222 | + |
| 223 | + def _parse_api_path(self, key: str) -> str: |
| 224 | + path = os.getenv(key, "").strip() |
| 225 | + if not path: |
| 226 | + return "" |
| 227 | + if not re.fullmatch(r"/[A-Za-z0-9/_-]{12,200}", path): |
| 228 | + raise ValueError(f"Invalid hidden API path for {key}") |
| 229 | + return path |
| 230 | + |
| 231 | + def _validate_matchscope_config(self) -> None: |
| 232 | + if self.MATCHSCOPE_PRIVATE_API_ENABLED: |
| 233 | + if not self.MATCHSCOPE_PRIVATE_API_PATH: |
| 234 | + raise ValueError("MATCHSCOPE_PRIVATE_API_PATH is required") |
| 235 | + if len(self.MATCHSCOPE_PRIVATE_API_TOKEN) < 32: |
| 236 | + raise ValueError("MATCHSCOPE_PRIVATE_API_TOKEN must be at least 32 characters") |
| 237 | + if self.MATCHSCOPE_PUBLIC_API_ENABLED: |
| 238 | + if not self.MATCHSCOPE_PUBLIC_API_PATH: |
| 239 | + raise ValueError("MATCHSCOPE_PUBLIC_API_PATH is required") |
| 240 | + if len(self.MATCHSCOPE_TOKEN_SIGNING_KEY) < 32: |
| 241 | + raise ValueError("MATCHSCOPE_TOKEN_SIGNING_KEY must be at least 32 characters") |
| 242 | + parsed_base_url = urlparse(self.MATCHSCOPE_PUBLIC_BASE_URL) |
| 243 | + if ( |
| 244 | + parsed_base_url.scheme not in {"http", "https"} |
| 245 | + or not parsed_base_url.netloc |
| 246 | + or parsed_base_url.username is not None |
| 247 | + or parsed_base_url.password is not None |
| 248 | + or parsed_base_url.query |
| 249 | + or parsed_base_url.fragment |
| 250 | + or parsed_base_url.path not in {"", "/"} |
| 251 | + ): |
| 252 | + raise ValueError("MATCHSCOPE_PUBLIC_BASE_URL must be an HTTP(S) origin") |
| 253 | + if not self.GROUP_CHECK_ENABLED: |
| 254 | + raise ValueError("Public MatchScope API requires group membership verification") |
| 255 | + if ( |
| 256 | + self.MATCHSCOPE_PRIVATE_API_ENABLED |
| 257 | + and self.MATCHSCOPE_PUBLIC_API_ENABLED |
| 258 | + and self.MATCHSCOPE_PRIVATE_API_HOST == self.MATCHSCOPE_PUBLIC_API_HOST |
| 259 | + and self.MATCHSCOPE_PRIVATE_API_PORT == self.MATCHSCOPE_PUBLIC_API_PORT |
| 260 | + ): |
| 261 | + raise ValueError("Private and public MatchScope APIs cannot share one listener") |
| 262 | + for prefix in ("MATCHSCOPE_PRIVATE_API", "MATCHSCOPE_PUBLIC_API"): |
| 263 | + certificate = getattr(self, f"{prefix}_TLS_CERT_FILE") |
| 264 | + key = getattr(self, f"{prefix}_TLS_KEY_FILE") |
| 265 | + if bool(certificate) != bool(key): |
| 266 | + raise ValueError(f"{prefix}_TLS_CERT_FILE and TLS_KEY_FILE must be set together") |
| 267 | + |
137 | 268 | def _parse_group_ids(self, ids_str: str) -> list: |
138 | 269 | """解析群组 ID 列表 |
139 | 270 | |
|
0 commit comments