wechatbridge-cli 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wechatbridge/config.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ wechatbridge Configuration Module
3
+ Settings with environment variable overrides.
4
+ """
5
+
6
+ import os
7
+ import logging
8
+
9
+ _BASE_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _parse_env_file(path: str) -> None:
14
+ """Parse a .env file and load variables into os.environ (does not override existing)."""
15
+ try:
16
+ with open(path, "r", encoding="utf-8") as f:
17
+ for line in f:
18
+ line = line.strip()
19
+ if line and not line.startswith("#") and "=" in line:
20
+ k, v = line.split("=", 1)
21
+ k, v = k.strip(), v.strip().strip("'\"")
22
+ if k and k not in os.environ:
23
+ os.environ[k] = v
24
+ except Exception as e:
25
+ logger.warning("Failed to load .env file %s: %s", path, e)
26
+
27
+
28
+ def _load_env_file():
29
+ """Automatically load .env file if present (precedence: explicit > XDG per-instance > XDG shared > repo root)."""
30
+ # 1. WECHATBRIDGE_ENV_FILE (explicit, highest priority)
31
+ env_path = os.getenv("WECHATBRIDGE_ENV_FILE")
32
+ if env_path:
33
+ if os.path.exists(env_path):
34
+ logger.info("加载 .env 文件: %s (由 WECHATBRIDGE_ENV_FILE 指定)", env_path)
35
+ _parse_env_file(env_path)
36
+ else:
37
+ logger.warning("WECHATBRIDGE_ENV_FILE 指定的 .env 文件不存在: %s", env_path)
38
+ return
39
+
40
+ # 2. XDG_CONFIG_HOME/wechatbridge/<instance>.env
41
+ xdg_config = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
42
+ instance = os.getenv("WECHATBRIDGE_INSTANCE", "default")
43
+ env_path = os.path.join(xdg_config, "wechatbridge", f"{instance}.env")
44
+ if os.path.exists(env_path):
45
+ logger.info("加载 .env 文件: %s", env_path)
46
+ _parse_env_file(env_path)
47
+ return
48
+
49
+ # 3. XDG_CONFIG_HOME/wechatbridge/.env (instance-independent shared)
50
+ env_path = os.path.join(xdg_config, "wechatbridge", ".env")
51
+ if os.path.exists(env_path):
52
+ logger.info("加载 .env 文件: %s", env_path)
53
+ _parse_env_file(env_path)
54
+ return
55
+
56
+ # 4. (Deprecated) Package parent directory — repo root .env
57
+ env_path = os.path.join(os.path.dirname(_BASE_DIR), ".env")
58
+ if os.path.exists(env_path):
59
+ logger.warning(
60
+ "已废弃: .env 文件位于 %s,请迁移到 %s",
61
+ env_path,
62
+ os.path.join(xdg_config, "wechatbridge", f"{instance}.env"),
63
+ )
64
+ _parse_env_file(env_path)
65
+
66
+
67
+ _DEPRECATED_ENV: dict = {} # "OLD_NAME": "NEW_NAME" — when old is set but new isn't, copy value and warn
68
+
69
+
70
+ def _apply_deprecated_env():
71
+ """Apply deprecated env var mappings: copy old value to new name."""
72
+ for old, new in _DEPRECATED_ENV.items():
73
+ old_val = os.getenv(old)
74
+ if old_val is not None and os.getenv(new) is None:
75
+ os.environ[new] = old_val
76
+ logger.warning("环境变量 %s 已废弃,请改用 %s(当前值已自动复制)", old, new)
77
+
78
+
79
+ _load_env_file()
80
+ _apply_deprecated_env()
81
+
82
+
83
+ def _env_int(name: str, default: int) -> int:
84
+ val = os.getenv(name)
85
+ if val is None:
86
+ return default
87
+ try:
88
+ return int(val)
89
+ except ValueError:
90
+ logger.warning("Invalid %s=%r, falling back to %d", name, val, default)
91
+ return default
92
+
93
+
94
+ def _env_float(name: str, default: float) -> float:
95
+ val = os.getenv(name)
96
+ if val is None:
97
+ return default
98
+ try:
99
+ return float(val)
100
+ except ValueError:
101
+ logger.warning("Invalid %s=%r, falling back to %f", name, val, default)
102
+ return default
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Instance identity — all per-instance paths derive from this
107
+ # ---------------------------------------------------------------------------
108
+ _instance = os.getenv("WECHATBRIDGE_INSTANCE", "default")
109
+ _instance_data_dir = os.path.join(
110
+ os.path.expanduser("~"), ".local", "share", "wechatbridge", _instance
111
+ )
112
+
113
+
114
+ class AppConfig:
115
+ # iLink base URL (no trailing slash)
116
+ ilink_base_url: str = os.getenv("ILINK_BASE_URL", "https://ilinkai.weixin.qq.com")
117
+
118
+ # Active CLI backend: "agy" or "grok" (global default, can be overridden per-user via /backend)
119
+ backend: str = os.getenv("WECHATBRIDGE_BACKEND", "agy").lower()
120
+ if backend not in ("agy", "grok"):
121
+ logger.warning("Unknown backend %r, falling back to 'agy'", backend)
122
+ backend = "agy"
123
+
124
+ # agy CLI binary path
125
+ agy_binary_path: str = os.getenv("AGY_BIN_PATH", "agy") # default assumes in PATH
126
+
127
+ # grok CLI binary path
128
+ grok_binary_path: str = os.getenv("GROK_BIN_PATH", "grok") # default assumes in PATH
129
+
130
+ # Instance name (for multi-instance deployments)
131
+ instance: str = _instance
132
+
133
+ # Per-instance paths (derived from instance, can be overridden by explicit env vars)
134
+ session_base_dir: str = os.getenv(
135
+ "WECHATBRIDGE_SESSION_DIR",
136
+ os.path.join(_instance_data_dir, "sessions"),
137
+ )
138
+
139
+ state_file_path: str = os.getenv(
140
+ "WECHATBRIDGE_STATE_FILE",
141
+ os.path.join(_instance_data_dir, ".ilink_state.json"),
142
+ )
143
+
144
+ qrcode_png_path: str = os.getenv(
145
+ "WECHATBRIDGE_QRCODE_PATH",
146
+ os.path.join(_instance_data_dir, "qrcode.png"),
147
+ )
148
+
149
+ qrcode_url_path: str = os.getenv(
150
+ "WECHATBRIDGE_QRCODE_URL_FILE",
151
+ os.path.join(_instance_data_dir, ".current_qrcode_url.txt"),
152
+ )
153
+
154
+ # Timeout for CLI execution (seconds) — default 600s; override via AGY_TIMEOUT
155
+ agy_timeout: int = _env_int("AGY_TIMEOUT", 600)
156
+
157
+ # QR code polling timeout (seconds)
158
+ qrcode_poll_timeout: int = _env_int("QRCODE_POLL_TIMEOUT", 180)
159
+
160
+ # QR code poll interval (seconds)
161
+ qrcode_poll_interval: float = _env_float("QRCODE_POLL_INTERVAL", 1.5)
162
+
163
+ # Log level
164
+ log_level: str = os.getenv("LOG_LEVEL", "INFO")
165
+
166
+ # iLink CDN base URL for image download
167
+ cdn_base_url: str = os.getenv("WECHATBRIDGE_CDN_BASE", "https://novac2c.cdn.weixin.qq.com/c2c")
168
+
169
+ # agy scratch directory (where agy writes generated files)
170
+ agy_scratch_dir: str = os.getenv("AGY_SCRATCH_DIR", os.path.expanduser("~/.gemini/antigravity-cli/scratch"))
171
+
172
+ # Global agy scratch retention days (TTL cleanup)
173
+ scratch_retention_days: int = _env_int("AGY_SCRATCH_RETENTION_DAYS", 7)
174
+
175
+ # Per-session temp cleanup (media, .cache, scratch/logs) — not dialogue history.
176
+ # Defaults to the same value as scratch_retention_days.
177
+ session_retention_days: int = _env_int(
178
+ "WECHATBRIDGE_SESSION_RETENTION_DAYS",
179
+ _env_int("AGY_SCRATCH_RETENTION_DAYS", 7),
180
+ )
181
+
182
+ # Dialogue history idle TTL: conversations/brain/grok sessions untouched this
183
+ # many days are deleted. Active chats (files still updated) are kept.
184
+ history_retention_days: int = _env_int(
185
+ "WECHATBRIDGE_HISTORY_RETENTION_DAYS", 30
186
+ )
187
+
188
+ # Maximum outbound file size (bytes) — 100 MB, Tencent OpenClaw SDK limit
189
+ max_outbound_file_bytes: int = _env_int("WECHATBRIDGE_MAX_OUTBOUND_BYTES", 100 * 1024 * 1024)
190
+
191
+ # Maximum inbound image/file size after download (bytes) — default 20 MB
192
+ max_inbound_file_bytes: int = _env_int("WECHATBRIDGE_MAX_INBOUND_BYTES", 20 * 1024 * 1024)
193
+
194
+ # Max concurrent message handlers (global). Extra messages get a busy reply.
195
+ max_concurrent_tasks: int = _env_int("WECHATBRIDGE_MAX_CONCURRENT", 4)
196
+
197
+ # WeChat text chunk size (characters) when splitting long replies
198
+ message_chunk_chars: int = _env_int("WECHATBRIDGE_MESSAGE_CHUNK", 2000)
199
+
200
+ # Extra roots allowed for /add-dir (comma-separated absolute paths).
201
+ # Session dir is always allowed. Empty = only session dir (and its children).
202
+ add_dir_roots: list = [
203
+ os.path.expanduser(s.strip())
204
+ for s in os.getenv("WECHATBRIDGE_ADD_DIR_ROOTS", "").split(",")
205
+ if s.strip()
206
+ ]
207
+
208
+ # CDN upload timeout (seconds)
209
+ cdn_upload_timeout: int = _env_int("CDN_UPLOAD_TIMEOUT", 120)
210
+
211
+ # Access control: comma-separated wxid list, empty = allow all
212
+ allowed_senders: list = [
213
+ s.strip()
214
+ for s in os.getenv("WECHATBRIDGE_ALLOWED_SENDERS", "").split(",")
215
+ if s.strip()
216
+ ]
217
+
218
+ # Admin users: comma-separated wxid list, receive update notifications etc.
219
+ admin_users: list = [
220
+ s.strip()
221
+ for s in os.getenv("WECHATBRIDGE_ADMINS", "").split(",")
222
+ if s.strip()
223
+ ]
224
+
225
+ # Periodic update check to PyPI
226
+ update_check_enabled: bool = os.getenv("WECHATBRIDGE_UPDATE_CHECK", "true").lower() == "true"
227
+ update_check_interval: int = _env_int("WECHATBRIDGE_UPDATE_CHECK_INTERVAL", 86400)
228
+
229
+ # Enable /mcp slash command (agy MCP tool guidance)
230
+ enable_mcp: bool = os.getenv("WECHATBRIDGE_ENABLE_MCP", "true").lower() == "true"
231
+
232
+ # Enable /agent slash command (subagent invocation)
233
+ enable_subagent: bool = os.getenv("WECHATBRIDGE_ENABLE_SUBAGENT", "true").lower() == "true"
234
+
235
+ # Confirm gate: dangerous prompt confirmation (empty = fallback to hardcoded list)
236
+ confirm_keywords: list = [
237
+ kw.strip()
238
+ for kw in os.getenv("WECHATBRIDGE_CONFIRM_KEYWORDS", "").split(",")
239
+ if kw.strip()
240
+ ]
241
+ # TTL for pending confirmations (seconds)
242
+ pending_confirm_ttl: int = _env_int("WECHATBRIDGE_PENDING_TTL", 300)
243
+ # Confirmation keyword users must reply to execute dangerous prompt
244
+ confirm_token: str = os.getenv("WECHATBRIDGE_CONFIRM_TOKEN", "y")
245
+
246
+
247
+ config = AppConfig()
248
+
249
+
250
+ def ensure_runtime_dirs() -> None:
251
+ """Create instance data / session / state / qrcode parent dirs with tight perms."""
252
+ paths = {
253
+ _instance_data_dir,
254
+ config.session_base_dir,
255
+ os.path.dirname(os.path.abspath(config.state_file_path)) or ".",
256
+ os.path.dirname(os.path.abspath(config.qrcode_png_path)) or ".",
257
+ os.path.dirname(os.path.abspath(config.qrcode_url_path)) or ".",
258
+ }
259
+ for path in paths:
260
+ if not path or path == ".":
261
+ continue
262
+ try:
263
+ os.makedirs(path, exist_ok=True)
264
+ os.chmod(path, 0o700)
265
+ except OSError as e:
266
+ logger.warning("Failed to ensure runtime dir %s: %s", path, e)