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/__init__.py +2 -0
- wechatbridge/__main__.py +4 -0
- wechatbridge/agy.py +630 -0
- wechatbridge/config.py +266 -0
- wechatbridge/grok.py +682 -0
- wechatbridge/ilink.py +849 -0
- wechatbridge/main.py +755 -0
- wechatbridge/runner_common.py +1003 -0
- wechatbridge/update_check.py +175 -0
- wechatbridge_cli-1.3.0.dist-info/METADATA +29 -0
- wechatbridge_cli-1.3.0.dist-info/RECORD +15 -0
- wechatbridge_cli-1.3.0.dist-info/WHEEL +5 -0
- wechatbridge_cli-1.3.0.dist-info/entry_points.txt +2 -0
- wechatbridge_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- wechatbridge_cli-1.3.0.dist-info/top_level.txt +1 -0
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)
|