vibego 0.2.52__py3-none-any.whl → 1.0.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.

Potentially problematic release.


This version of vibego might be problematic. Click here for more details.

master.py CHANGED
@@ -1,10 +1,10 @@
1
1
  """Master bot controller.
2
2
 
3
- 统一管理多个项目 worker:
4
- - 读取 `config/master.db`(自动同步 `config/projects.json`)获取项目配置
5
- - 维护 `state/state.json`,记录运行状态 / 当前模型 / 自动记录的 chat_id
6
- - 暴露 /projects、/run、/stop、/switch、/authorize 等命令
7
- - 调用 `scripts/run_bot.sh` / `scripts/stop_bot.sh` 控制 worker 进程
3
+ Responsibilities:
4
+ - Read `config/master.db` (kept in sync with `config/projects.json`) to load project configuration.
5
+ - Maintain `state/state.json`, recording runtime status, the active model, and automatically captured chat IDs.
6
+ - Expose the /projects, /run, /stop, /switch, and /authorize administrator commands.
7
+ - Invoke `scripts/run_bot.sh` / `scripts/stop_bot.sh` to manage worker processes.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
@@ -22,7 +22,8 @@ import textwrap
22
22
  import re
23
23
  import threading
24
24
  import unicodedata
25
- from datetime import datetime, timezone
25
+ import urllib.request
26
+ from datetime import datetime, timezone, timedelta
26
27
  from zoneinfo import ZoneInfo
27
28
  from dataclasses import dataclass, field
28
29
  from pathlib import Path
@@ -57,30 +58,72 @@ from project_repository import ProjectRepository, ProjectRecord
57
58
  from tasks.fsm import ProjectDeleteStates
58
59
  from vibego_cli import __version__
59
60
 
61
+ try:
62
+ from packaging.version import Version, InvalidVersion
63
+ except ImportError: # pragma: no cover
64
+ Version = None # type: ignore[assignment]
65
+
66
+ class InvalidVersion(Exception):
67
+ """Placeholder exception, compatible with version parsing errors when packaging is missing. """
68
+
60
69
  ROOT_DIR = Path(__file__).resolve().parent
61
- CONFIG_PATH = Path(os.environ.get("MASTER_PROJECTS_PATH", ROOT_DIR / "config/projects.json"))
62
- CONFIG_DB_PATH = Path(os.environ.get("MASTER_PROJECTS_DB_PATH", ROOT_DIR / "config/master.db"))
63
- STATE_PATH = Path(os.environ.get("MASTER_STATE_PATH", ROOT_DIR / "state/state.json"))
70
+ def _default_config_root() -> Path:
71
+ """
72
+ Parse the configuration root directory, be compatible with multiple environment variables and fall back to the XDG specification.
73
+
74
+ Priority:
75
+ 1. MASTER_CONFIG_ROOT(for master.py use)
76
+ 2. VIBEGO_CONFIG_DIR(CLI Entrance settings)
77
+ 3. $XDG_CONFIG_HOME/vibego or ~/.config/vibego
78
+ """
79
+ override = os.environ.get("MASTER_CONFIG_ROOT") or os.environ.get("VIBEGO_CONFIG_DIR")
80
+ if override:
81
+ return Path(override).expanduser()
82
+ xdg_base = os.environ.get("XDG_CONFIG_HOME")
83
+ base = Path(xdg_base).expanduser() if xdg_base else Path.home() / ".config"
84
+ return base / "vibego"
85
+
86
+
87
+ CONFIG_ROOT = _default_config_root()
88
+ CONFIG_DIR = CONFIG_ROOT / "config"
89
+ STATE_DIR = CONFIG_ROOT / "state"
90
+ LOG_DIR = CONFIG_ROOT / "logs"
91
+
92
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
93
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
94
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
95
+
96
+ CONFIG_PATH = Path(os.environ.get("MASTER_PROJECTS_PATH", CONFIG_DIR / "projects.json"))
97
+ CONFIG_DB_PATH = Path(os.environ.get("MASTER_PROJECTS_DB_PATH", CONFIG_DIR / "master.db"))
98
+ STATE_PATH = Path(os.environ.get("MASTER_STATE_PATH", STATE_DIR / "state.json"))
64
99
  RUN_SCRIPT = ROOT_DIR / "scripts/run_bot.sh"
65
100
  STOP_SCRIPT = ROOT_DIR / "scripts/stop_bot.sh"
66
101
 
102
+ UPDATE_STATE_PATH = STATE_DIR / "update_state.json"
103
+ UPDATE_CHECK_INTERVAL = timedelta(hours=24)
104
+ _UPDATE_STATE_LOCK = threading.Lock()
105
+
67
106
 
68
107
  def _get_restart_signal_path() -> Path:
69
108
  """
70
- 获取重启信号文件路径,使用健壮的默认值逻辑。
109
+ Get the restart signal file path, using robust default value logic.
71
110
 
72
- 优先级:
73
- 1. 环境变量 MASTER_RESTART_SIGNAL_PATH
74
- 2. 配置目录 $MASTER_CONFIG_ROOT/state/restart_signal.json
75
- 3. 代码目录 ROOT_DIR/state/restart_signal.json(兜底)
111
+ Priority:
112
+ 1. Environment variable MASTER_RESTART_SIGNAL_PATH
113
+ 2. Configuration directory $MASTER_CONFIG_ROOT/state/restart_signal.json
114
+ 3. Code directory ROOT_DIR/state/restart_signal.json(full details)
76
115
 
77
- 这样可以确保 pipx 安装的版本和源码运行的版本使用同一个信号文件。
116
+ This ensures that the version installed by pipx and the version run from the source code use the same signal file.
78
117
  """
79
118
  if env_path := os.environ.get("MASTER_RESTART_SIGNAL_PATH"):
80
119
  return Path(env_path)
81
120
 
82
- # 默认使用配置目录而非代码目录,确保跨安装方式的一致性
83
- config_root = Path(os.environ.get("MASTER_CONFIG_ROOT", Path.home() / ".config/vibego"))
121
+ # Use the configuration directory instead of the code directory by default to ensure consistency across installation methods
122
+ config_root_raw = (
123
+ os.environ.get("MASTER_CONFIG_ROOT")
124
+ or os.environ.get("VIBEGO_CONFIG_DIR")
125
+ )
126
+ config_root = Path(config_root_raw).expanduser() if config_root_raw else _default_config_root()
84
127
  return config_root / "state/restart_signal.json"
85
128
 
86
129
 
@@ -90,18 +133,18 @@ LEGACY_RESTART_SIGNAL_PATHS: Tuple[Path, ...] = tuple(
90
133
  for path in (ROOT_DIR / "state/restart_signal.json",)
91
134
  if path != RESTART_SIGNAL_PATH
92
135
  )
93
- RESTART_SIGNAL_TTL = int(os.environ.get("MASTER_RESTART_SIGNAL_TTL", "1800")) # 默认 30 分钟
136
+ RESTART_SIGNAL_TTL = int(os.environ.get("MASTER_RESTART_SIGNAL_TTL", "1800")) # Default 30 minutes
94
137
  LOCAL_TZ = ZoneInfo(os.environ.get("MASTER_TIMEZONE", "Asia/Shanghai"))
95
138
  JUMP_BUTTON_TEXT_WIDTH = 40
96
139
 
97
- _DEFAULT_LOG_ROOT = ROOT_DIR / "logs"
140
+ _DEFAULT_LOG_ROOT = LOG_DIR
98
141
  LOG_ROOT_PATH = Path(os.environ.get("LOG_ROOT", str(_DEFAULT_LOG_ROOT))).expanduser()
99
142
 
100
143
  WORKER_HEALTH_TIMEOUT = float(os.environ.get("WORKER_HEALTH_TIMEOUT", "20"))
101
144
  WORKER_HEALTH_INTERVAL = float(os.environ.get("WORKER_HEALTH_INTERVAL", "0.5"))
102
145
  WORKER_HEALTH_LOG_TAIL = int(os.environ.get("WORKER_HEALTH_LOG_TAIL", "80"))
103
146
  HANDSHAKE_MARKERS = (
104
- "Telegram 连接正常",
147
+ "Telegram The connection is OK",
105
148
  )
106
149
  DELETE_CONFIRM_TIMEOUT = int(os.environ.get("MASTER_DELETE_CONFIRM_TIMEOUT", "120"))
107
150
 
@@ -109,24 +152,25 @@ _ENV_FILE_RAW = os.environ.get("MASTER_ENV_FILE")
109
152
  MASTER_ENV_FILE = Path(_ENV_FILE_RAW).expanduser() if _ENV_FILE_RAW else None
110
153
  _ENV_LOCK = threading.Lock()
111
154
 
112
- MASTER_MENU_BUTTON_TEXT = "📂 项目列表"
113
- # 旧版本键盘的文案,用于兼容仍显示英文的客户端消息
155
+ MASTER_MENU_BUTTON_TEXT = "📂 Project list"
156
+ # Copywriting for the old version of the keyboard, for compatibility with client messages that still display in English
114
157
  MASTER_MENU_BUTTON_LEGACY_TEXTS: Tuple[str, ...] = ("📂 Projects",)
115
- # 允许触发项目列表的全部文案,优先匹配最新文案
158
+ # All copywriting in the project list is allowed to be triggered, and the latest copywriting will be matched first.
116
159
  MASTER_MENU_BUTTON_ALLOWED_TEXTS: Tuple[str, ...] = (
117
160
  MASTER_MENU_BUTTON_TEXT,
118
161
  *MASTER_MENU_BUTTON_LEGACY_TEXTS,
119
162
  )
120
- MASTER_MANAGE_BUTTON_TEXT = "⚙️ 项目管理"
163
+ MASTER_MANAGE_BUTTON_TEXT = "⚙️ Project Management"
121
164
  MASTER_MANAGE_BUTTON_ALLOWED_TEXTS: Tuple[str, ...] = (MASTER_MANAGE_BUTTON_TEXT,)
122
165
  MASTER_BOT_COMMANDS: List[Tuple[str, str]] = [
123
- ("start", "启动 master 菜单"),
124
- ("projects", "查看项目列表"),
125
- ("run", "启动 worker"),
126
- ("stop", "停止 worker"),
127
- ("switch", "切换 worker 模型"),
128
- ("authorize", "登记 chat"),
129
- ("restart", "重启 master"),
166
+ ("start", "Start master menu"),
167
+ ("projects", "View project list"),
168
+ ("run", "Start worker"),
169
+ ("stop", "Stop worker"),
170
+ ("switch", "Switch worker model"),
171
+ ("authorize", "Register chat"),
172
+ ("restart", "Restart master"),
173
+ ("upgrade", "Upgrade vibego to the latest version"),
130
174
  ]
131
175
  MASTER_BROADCAST_MESSAGE = os.environ.get("MASTER_BROADCAST_MESSAGE", "")
132
176
  SWITCHABLE_MODELS: Tuple[Tuple[str, str], ...] = (
@@ -134,12 +178,12 @@ SWITCHABLE_MODELS: Tuple[Tuple[str, str], ...] = (
134
178
  ("claudecode", "⚙️ ClaudeCode"),
135
179
  )
136
180
 
137
- # Telegram 在不同客户端可能插入零宽字符或额外空白,提前归一化按钮文本。
181
+ # Telegram Different clients may insert zero-width characters or extra whitespace to normalize button text in advance.
138
182
  ZERO_WIDTH_CHARACTERS: Tuple[str, ...] = ("\u200b", "\u200c", "\u200d", "\ufeff")
139
183
 
140
184
 
141
185
  def _normalize_button_text(text: str) -> str:
142
- """归一化项目按钮文本,剔除零宽字符并统一大小写。"""
186
+ """Normalize item button text, strip out zero-width characters and unify case. """
143
187
 
144
188
  filtered = "".join(ch for ch in text if ch not in ZERO_WIDTH_CHARACTERS)
145
189
  compacted = re.sub(r"\s+", " ", filtered).strip()
@@ -150,11 +194,11 @@ MASTER_MENU_BUTTON_CANONICAL_NORMALIZED = _normalize_button_text(MASTER_MENU_BUT
150
194
  MASTER_MENU_BUTTON_ALLOWED_NORMALIZED = {
151
195
  _normalize_button_text(value) for value in MASTER_MENU_BUTTON_ALLOWED_TEXTS
152
196
  }
153
- MASTER_MENU_BUTTON_KEYWORDS: Tuple[str, ...] = ("项目列表", "project", "projects")
197
+ MASTER_MENU_BUTTON_KEYWORDS: Tuple[str, ...] = ("Project list", "project", "projects")
154
198
 
155
199
 
156
200
  def _is_projects_menu_trigger(text: Optional[str]) -> bool:
157
- """判断消息文本是否可触发项目列表展示。"""
201
+ """Determine whether the message text can trigger the display of the project list. """
158
202
 
159
203
  if not text:
160
204
  return False
@@ -167,13 +211,13 @@ def _is_projects_menu_trigger(text: Optional[str]) -> bool:
167
211
 
168
212
 
169
213
  def _text_equals_master_button(text: str) -> bool:
170
- """判断文本是否等同于当前主按钮文案(允许空白差异)。"""
214
+ """Determine whether the text is equal to the current main button copy (blank differences are allowed). """
171
215
 
172
216
  return _normalize_button_text(text) == MASTER_MENU_BUTTON_CANONICAL_NORMALIZED
173
217
 
174
218
 
175
219
  def _build_master_main_keyboard() -> ReplyKeyboardMarkup:
176
- """构造 Master Bot 主键盘,提供项目列表与管理入口。"""
220
+ """Construct the Master Bot main keyboard, providing project list and management entrance. """
177
221
  return ReplyKeyboardMarkup(
178
222
  keyboard=[
179
223
  [
@@ -186,20 +230,20 @@ def _build_master_main_keyboard() -> ReplyKeyboardMarkup:
186
230
 
187
231
 
188
232
  async def _ensure_master_menu_button(bot: Bot) -> None:
189
- """同步 master 端聊天菜单按钮文本,修复旧客户端的缓存问题。"""
233
+ """Synchronize the chat menu button text on the master side and fix the cache problem of the old client. """
190
234
  try:
191
235
  await bot.set_chat_menu_button(
192
236
  menu_button=MenuButtonCommands(text=MASTER_MENU_BUTTON_TEXT),
193
237
  )
194
238
  except TelegramBadRequest as exc:
195
- log.warning("设置聊天菜单失败:%s", exc)
239
+ log.warning("Failed to set chat menu: %s", exc)
196
240
  else:
197
- log.info("聊天菜单已同步", extra={"text": MASTER_MENU_BUTTON_TEXT})
241
+ log.info("Chat menu has been synchronized", extra={"text": MASTER_MENU_BUTTON_TEXT})
198
242
 
199
243
 
200
244
  async def _ensure_master_commands(bot: Bot) -> None:
201
- """同步 master 侧命令列表,确保新增/删除命令立即生效。"""
202
- commands = [BotCommand(command=cmd, description=desc) for cmd, desc in MASTER_BOT_COMMANDS]
245
+ """Synchronize the command list on the master side to ensure that new/deleted commands take effect immediately. """
246
+ commands= [BotCommand(command=cmd, description=desc) for cmd, desc in MASTER_BOT_COMMANDS]
203
247
  scopes: List[Tuple[Optional[object], str]] = [
204
248
  (None, "default"),
205
249
  (BotCommandScopeAllPrivateChats(), "all_private"),
@@ -213,13 +257,13 @@ async def _ensure_master_commands(bot: Bot) -> None:
213
257
  else:
214
258
  await bot.set_my_commands(commands, scope=scope)
215
259
  except TelegramBadRequest as exc:
216
- log.warning("设置 master 命令失败:%s", exc, extra={"scope": label})
260
+ log.warning("Set master command failed: %s", exc, extra={"scope": label})
217
261
  else:
218
- log.info("master 命令已同步", extra={"scope": label})
262
+ log.info("master Command synchronized", extra={"scope": label})
219
263
 
220
264
 
221
265
  def _collect_master_broadcast_targets(manager: MasterManager) -> List[int]:
222
- """汇总需要推送键盘的 chat_id,避免重复广播。"""
266
+ """Summarize the chat_id that needs to be pushed to the keyboard to avoid repeated broadcasts. """
223
267
  targets: set[int] = set(manager.admin_ids or [])
224
268
  manager.refresh_state()
225
269
  for state in manager.state_store.data.values():
@@ -229,14 +273,14 @@ def _collect_master_broadcast_targets(manager: MasterManager) -> List[int]:
229
273
 
230
274
 
231
275
  async def _broadcast_master_keyboard(bot: Bot, manager: MasterManager) -> None:
232
- """ master 启动阶段主动推送菜单键盘,覆盖 Telegram 端缓存。"""
276
+ """During the master startup phase, the menu keyboard is actively pushed to overwrite the Telegram side cache. """
233
277
  targets = _collect_master_broadcast_targets(manager)
234
- # 当广播消息为空时表示不再向管理员推送启动提示,满足“禁止发送 /task_list”需求。
278
+ # When the broadcast message is empty, it means that the startup prompt will no longer be pushed to the administrator, meeting the requirement of "prohibit sending /task_list".
235
279
  if not MASTER_BROADCAST_MESSAGE:
236
- log.info("启动广播已禁用,跳过 master 键盘推送。")
280
+ log.info("Startup broadcast disabled, skipping master keyboard push. ")
237
281
  return
238
282
  if not targets:
239
- log.info("无可推送的 master 聊天对象")
283
+ log.info("No master chat objects to push")
240
284
  return
241
285
  markup = _build_master_main_keyboard()
242
286
  for chat_id in targets:
@@ -247,25 +291,25 @@ async def _broadcast_master_keyboard(bot: Bot, manager: MasterManager) -> None:
247
291
  reply_markup=markup,
248
292
  )
249
293
  except TelegramForbiddenError as exc:
250
- log.warning("推送菜单被禁止:%s", exc, extra={"chat": chat_id})
294
+ log.warning("Push menu disabled: %s", exc, extra={"chat": chat_id})
251
295
  except TelegramBadRequest as exc:
252
- log.warning("推送菜单失败:%s", exc, extra={"chat": chat_id})
296
+ log.warning("Push menu failed: %s", exc, extra={"chat": chat_id})
253
297
  except Exception as exc:
254
- log.error("推送菜单异常:%s", exc, extra={"chat": chat_id})
298
+ log.error("Push menu exception: %s", exc, extra={"chat": chat_id})
255
299
  else:
256
- log.info("已推送菜单至 chat_id=%s", chat_id)
300
+ log.info("Menu pushed to chat_id=%s", chat_id)
257
301
 
258
302
 
259
303
  def _ensure_numbered_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[InlineKeyboardMarkup]:
260
- """ InlineKeyboard 保持原始文案,不再自动追加编号。"""
304
+ """Keep the original copy for InlineKeyboard and no longer automatically append numbers. """
261
305
  return markup
262
306
 
263
307
 
264
308
  def _get_project_runtime_state(manager: "MasterManager", slug: str) -> Optional["ProjectState"]:
265
- """归一化查询项目运行状态,避免误用 FSMContext
309
+ """Normalize query project running status to avoid misuse of FSMContext.
266
310
 
267
- 这里集中处理 slug 大小写并注释说明原因,防止在路由中覆盖 aiogram
268
- 提供的 `FSMContext`(详见官方文档:https://docs.aiogram.dev/en/dev-3.x/dispatcher/fsm/context.html)。
311
+ Here we focus on handling the case of slug and commenting on the reasons to prevent overwriting aiogram in routing.
312
+ provided `FSMContext`(For details, please see the official documentation: https://docs.aiogram.dev/en/dev-3.x/dispatcher/fsm/context.html).
269
313
  """
270
314
 
271
315
  normalized = (slug or "").strip().lower()
@@ -281,7 +325,7 @@ def _get_project_runtime_state(manager: "MasterManager", slug: str) -> Optional[
281
325
 
282
326
 
283
327
  def _terminate_other_master_processes(grace: float = 3.0) -> None:
284
- """在新 master 启动后终止其他残留 master 进程"""
328
+ """Terminate other remaining master processes after the new master starts"""
285
329
  existing: list[int] = []
286
330
  try:
287
331
  result = subprocess.run(
@@ -307,7 +351,7 @@ def _terminate_other_master_processes(grace: float = 3.0) -> None:
307
351
  except ProcessLookupError:
308
352
  continue
309
353
  except PermissionError as exc:
310
- log.warning("终止残留 master 进程失败: %s", exc, extra={"pid": pid})
354
+ log.warning("Failed to terminate residual master process: %s", exc, extra={"pid": pid})
311
355
  if not existing:
312
356
  return
313
357
  deadline = time.monotonic() + grace
@@ -325,14 +369,14 @@ def _terminate_other_master_processes(grace: float = 3.0) -> None:
325
369
  except ProcessLookupError:
326
370
  continue
327
371
  except PermissionError as exc:
328
- log.warning("强制终止 master 进程失败: %s", exc, extra={"pid": pid})
372
+ log.warning("Forced termination of master process failed: %s", exc, extra={"pid": pid})
329
373
  if existing:
330
- log.info("清理其他 master 进程完成", extra={"terminated": existing, "force": list(alive)})
374
+ log.info("Cleaning up other master processes completed", extra={"terminated": existing, "force": list(alive)})
331
375
 
332
376
 
333
377
 
334
378
  def load_env(file: str = ".env") -> None:
335
- """加载默认 .env 以及 MASTER_ENV_FILE 指向的配置。"""
379
+ """load default .env and the configuration pointed to by MASTER_ENV_FILE. """
336
380
 
337
381
  candidates: List[Path] = []
338
382
  if MASTER_ENV_FILE:
@@ -351,7 +395,7 @@ def load_env(file: str = ".env") -> None:
351
395
 
352
396
 
353
397
  def _collect_admin_targets() -> List[int]:
354
- """汇总所有潜在管理员 chat_id,避免广播遗漏。"""
398
+ """Aggregate all potential admin chat_ids to avoid missing broadcasts. """
355
399
 
356
400
  if MANAGER is not None and getattr(MANAGER, "admin_ids", None):
357
401
  return sorted(MANAGER.admin_ids)
@@ -370,7 +414,7 @@ def _collect_admin_targets() -> List[int]:
370
414
 
371
415
 
372
416
  def _kill_existing_tmux(prefix: str) -> None:
373
- """终止所有匹配前缀的 tmux 会话,避免多实例冲突。"""
417
+ """Terminate all tmux sessions matching the prefix to avoid multi-instance conflicts. """
374
418
 
375
419
  if shutil.which("tmux") is None:
376
420
  return
@@ -395,7 +439,7 @@ def _kill_existing_tmux(prefix: str) -> None:
395
439
 
396
440
 
397
441
  def _mask_proxy(url: str) -> str:
398
- """隐藏代理 URL 中的凭据,仅保留主机与端口。"""
442
+ """Hide credentials in the proxy URL, leaving only the host and port. """
399
443
 
400
444
  if "@" not in url:
401
445
  return url
@@ -407,9 +451,9 @@ def _mask_proxy(url: str) -> str:
407
451
 
408
452
 
409
453
  def _parse_env_file(path: Path) -> Dict[str, str]:
410
- """读取 .env 文件并返回键值映射。"""
454
+ """read .env file and returns a key-value map. """
411
455
 
412
- result: Dict[str, str] = {}
456
+ result:Dict[str, str] = {}
413
457
  if not path.exists():
414
458
  return result
415
459
  try:
@@ -420,12 +464,12 @@ def _parse_env_file(path: Path) -> Dict[str, str]:
420
464
  key, value = line.split("=", 1)
421
465
  result[key.strip()] = value.strip()
422
466
  except Exception as exc: # pylint: disable=broad-except
423
- log.warning("解析 MASTER_ENV_FILE 失败: %s", exc, extra={"path": str(path)})
467
+ log.warning("Failed to parse MASTER_ENV_FILE: %s", exc, extra={"path": str(path)})
424
468
  return result
425
469
 
426
470
 
427
471
  def _dump_env_file(path: Path, values: Dict[str, str]) -> None:
428
- """写入 .env,默认采用 600 权限。"""
472
+ """write .env,The default is 600 permissions. """
429
473
 
430
474
  try:
431
475
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -436,11 +480,11 @@ def _dump_env_file(path: Path, values: Dict[str, str]) -> None:
436
480
  except PermissionError:
437
481
  pass
438
482
  except Exception as exc: # pylint: disable=broad-except
439
- log.warning("写入 MASTER_ENV_FILE 失败: %s", exc, extra={"path": str(path)})
483
+ log.warning("Failed to write to MASTER_ENV_FILE: %s", exc, extra={"path": str(path)})
440
484
 
441
485
 
442
486
  def _update_master_env(chat_id: Optional[int], user_id: Optional[int]) -> None:
443
- """将最近一次 master 交互信息写入 .env"""
487
+ """Write the latest master interaction information .env."""
444
488
 
445
489
  if not MASTER_ENV_FILE:
446
490
  return
@@ -464,7 +508,7 @@ def _update_master_env(chat_id: Optional[int], user_id: Optional[int]) -> None:
464
508
 
465
509
 
466
510
  def _format_project_line(cfg: "ProjectConfig", state: Optional[ProjectState]) -> str:
467
- """格式化项目状态信息,用于日志与通知。"""
511
+ """Format project status information for logging and notifications. """
468
512
 
469
513
  status = state.status if state else "stopped"
470
514
  model = state.model if state else cfg.default_model
@@ -475,7 +519,7 @@ def _format_project_line(cfg: "ProjectConfig", state: Optional[ProjectState]) ->
475
519
 
476
520
 
477
521
  def _projects_overview(manager: MasterManager) -> Tuple[str, Optional[InlineKeyboardMarkup]]:
478
- """根据当前项目状态生成概览文本与操作按钮。"""
522
+ """Generate overview text and action buttons based on the current project status. """
479
523
 
480
524
  builder = InlineKeyboardBuilder()
481
525
  button_count = 0
@@ -492,7 +536,7 @@ def _projects_overview(manager: MasterManager) -> Tuple[str, Optional[InlineKeyb
492
536
  url=cfg.jump_url,
493
537
  ),
494
538
  InlineKeyboardButton(
495
- text=f"⛔️ 停止 ({current_model_label})",
539
+ text=f"⛔️ Stop ({current_model_label})",
496
540
  callback_data=f"project:stop:{cfg.project_slug}",
497
541
  ),
498
542
  )
@@ -503,30 +547,248 @@ def _projects_overview(manager: MasterManager) -> Tuple[str, Optional[InlineKeyb
503
547
  url=cfg.jump_url,
504
548
  ),
505
549
  InlineKeyboardButton(
506
- text=f"▶️ 启动 ({current_model_label})",
550
+ text=f"▶️ Start ({current_model_label})",
507
551
  callback_data=f"project:run:{cfg.project_slug}",
508
552
  ),
509
553
  )
510
554
  button_count += 1
511
555
  builder.row(
512
- InlineKeyboardButton(text="🚀 启动全部项目", callback_data="project:start_all:*")
556
+ InlineKeyboardButton(text="🚀 Start all projects", callback_data="project:start_all:*")
513
557
  )
514
558
  builder.row(
515
- InlineKeyboardButton(text="⛔️ 停止全部项目", callback_data="project:stop_all:*")
559
+ InlineKeyboardButton(text="⛔️ Stop all projects", callback_data="project:stop_all:*")
516
560
  )
517
561
  builder.row(
518
- InlineKeyboardButton(text="🔄 重启 Master", callback_data="project:restart_master:*")
562
+ InlineKeyboardButton(text="🔄 Restart Master", callback_data="project:restart_master:*")
519
563
  )
520
564
  markup = builder.as_markup()
521
565
  markup = _ensure_numbered_markup(markup)
522
- log.info("项目概览生成按钮数量=%s", button_count)
566
+ log.info("Project overview generated button count=%s", button_count)
523
567
  if button_count == 0:
524
- return "暂无项目配置,请在“⚙️ 项目管理”创建新项目后再尝试。", markup
525
- return "请选择操作:", markup
568
+ return (
569
+ 'No project configuration found. Open "⚙️ Project Management" to create a new project and try again.',
570
+ markup,
571
+ )
572
+ return "Please select an action:", markup
573
+
574
+
575
+ def _utcnow() -> datetime:
576
+ """Returns the current time in UTC for easy serialization. """
577
+
578
+ return datetime.now(timezone.utc)
579
+
580
+
581
+ def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
582
+ """Parse ISO8601 string into UTC time, return None in case of exception. """
583
+
584
+ if not value:
585
+ return None
586
+ try:
587
+ parsed=datetime.fromisoformat(value)
588
+ except ValueError:
589
+ return None
590
+ if parsed.tzinfo is None:
591
+ return parsed.replace(tzinfo=timezone.utc)
592
+ return parsed.astimezone(timezone.utc)
593
+
594
+
595
+ def _load_update_state() -> Dict[str, Any]:
596
+ """Read update detection status, returning an empty dictionary on failure. """
597
+
598
+ with _UPDATE_STATE_LOCK:
599
+ if not UPDATE_STATE_PATH.exists():
600
+ return {}
601
+ try:
602
+ raw = UPDATE_STATE_PATH.read_text(encoding="utf-8")
603
+ state = json.loads(raw) if raw.strip() else {}
604
+ if not isinstance(state, dict):
605
+ state = {}
606
+ return state
607
+ except Exception as exc: # pragma: no cover - It will only be triggered under extreme circumstances
608
+ log.warning("Failed to read update status: %s", exc)
609
+ return {}
610
+
611
+
612
+ def _save_update_state(state: Dict[str, Any]) -> None:
613
+ """Persistent update state to ensure atomic writes. """
614
+
615
+ with _UPDATE_STATE_LOCK:
616
+ tmp_path = UPDATE_STATE_PATH.with_suffix(".tmp")
617
+ tmp_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
618
+ tmp_path.replace(UPDATE_STATE_PATH)
619
+
620
+
621
+ def _ensure_notified_list(state: Dict[str, Any]) -> List[int]:
622
+ """The notification list is guaranteed to exist in the state and a mutable reference is returned. """
623
+
624
+ notified = state.get("notified_chat_ids")
625
+ if isinstance(notified, list):
626
+ filtered = []
627
+ for item in notified:
628
+ try:
629
+ filtered.append(int(item))
630
+ except (TypeError, ValueError):
631
+ continue
632
+ state["notified_chat_ids"] = filtered
633
+ return filtered
634
+ state["notified_chat_ids"] = []
635
+ return state["notified_chat_ids"]
636
+
637
+
638
+ async def _fetch_latest_version() -> Optional[str]:
639
+ """Query the latest version of vibego from PyPI, and return None when the network is abnormal. """
640
+
641
+ url=os.environ.get("VIBEGO_PYPI_JSON", "https://pypi.org/pypi/vibego/json")
642
+
643
+ def _request() -> Optional[str]:
644
+ try:
645
+ with urllib.request.urlopen(url, timeout=10) as resp:
646
+ payload = json.load(resp)
647
+ except Exception as exc: # pragma: no cover - Triggered when network abnormality occurs
648
+ log.warning("Failed to get latest version of vibego: %s", exc)
649
+ return None
650
+ info = payload.get("info") if isinstance(payload, dict) else None
651
+ version = info.get("version") if isinstance(info, dict) else None
652
+ if isinstance(version, str) and version.strip():
653
+ return version.strip()
654
+ return None
655
+
656
+ return await asyncio.to_thread(_request)
657
+
658
+
659
+ def _is_newer_version(latest: str, current: str) -> bool:
660
+ """Compare version numbers and use packaging parsing first. """
661
+
662
+ if not latest or latest == current:
663
+ return False
664
+ if Version is not None:
665
+ try:
666
+ return Version(latest) > Version(current)
667
+ except InvalidVersion:
668
+ pass
669
+ # Fallback strategy: segment comparison by semantic version
670
+ def _split(value: str) -> Tuple[int, ...]:
671
+ parts: List[int] = []
672
+ for chunk in value.replace("-", ".").split("."):
673
+ if not chunk:
674
+ continue
675
+ if chunk.isdigit():
676
+ parts.append(int(chunk))
677
+ else:
678
+ return tuple(parts)
679
+ return tuple(parts)
680
+
681
+ return _split(latest) > _split(current)
682
+
683
+
684
+ async def _ensure_update_state(force: bool = False) -> Dict[str, Any]:
685
+ """Refresh the update status on demand, and trigger a network request every 24 hours by default. """
686
+
687
+ state = _load_update_state()
688
+ now = _utcnow()
689
+ last_check = _parse_iso_datetime(state.get("last_check"))
690
+ need_check = force or last_check is None or (now - last_check) >= UPDATE_CHECK_INTERVAL
691
+ if not need_check:
692
+ return state
693
+
694
+ latest = await _fetch_latest_version()
695
+ state["last_check"] = now.isoformat()
696
+ if latest:
697
+ previous = state.get("latest_version")
698
+ state["latest_version"] = latest
699
+ if previous != latest:
700
+ # Reset the notification record when a new version appears to avoid missing reminders
701
+ state["last_notified_version"] = ""
702
+ state["notified_chat_ids"] = []
703
+ state["last_notified_at"] = None
704
+ _save_update_state(state)
705
+ return state
706
+
707
+
708
+ async def _maybe_notify_update(
709
+ bot: Bot,
710
+ chat_id: int,
711
+ *,
712
+ force_check: bool = False,
713
+ state: Optional[Dict[str, Any]] = None,
714
+ ) -> bool:
715
+ """Send a reminder if a new version is detected and the current chat has not been notified. """
716
+
717
+ current_state = state if state is not None else await _ensure_update_state(force=force_check)
718
+ latest = current_state.get("latest_version")
719
+ if not isinstance(latest, str) or not latest.strip():
720
+ return False
721
+ latest = latest.strip()
722
+ if not _is_newer_version(latest, __version__):
723
+ return False
724
+
725
+ notified_ids = _ensure_notified_list(current_state)
726
+ if chat_id in notified_ids:
727
+ return False
728
+
729
+ message = (
730
+ f"The latest vibego version v{latest} has been detected and the current running version is v{__version__}. \n"
731
+ "Send /upgrade to automatically perform the upgrade and restart the service. "
732
+ )
733
+ try:
734
+ await bot.send_message(chat_id=chat_id, text=message)
735
+ except Exception as exc:
736
+ log.warning("Failed to send upgrade reminder (chat=%s): %s", chat_id, exc)
737
+ return False
738
+
739
+ notified_ids.append(chat_id)
740
+ current_state["last_notified_version"] = latest
741
+ current_state["last_notified_at"] = _utcnow().isoformat()
742
+ _save_update_state(current_state)
743
+ return True
744
+
745
+
746
+ async def _notify_update_to_targets(bot: Bot, targets: Sequence[int], *, force_check: bool = False) -> None:
747
+ """Push available updates to administrators in bulk. """
748
+
749
+ if not targets:
750
+ return
751
+ state = await _ensure_update_state(force=force_check)
752
+ sent = 0
753
+ for chat_id in targets:
754
+ if await _maybe_notify_update(bot, chat_id, state=state):
755
+ sent += 1
756
+ if sent:
757
+ log.info("Upgrade reminder has been pushed to %s administrators", sent)
758
+
759
+
760
+ def _trigger_upgrade_pipeline() -> Tuple[bool, Optional[str]]:
761
+ """Trigger the pipx upgrade process and run it in the background. """
762
+
763
+ command = "pipx upgrade vibego && vibego stop && vibego start"
764
+ try:
765
+ subprocess.Popen(
766
+ ["/bin/bash", "-lc", command],
767
+ cwd=str(ROOT_DIR),
768
+ stdout=subprocess.DEVNULL,
769
+ stderr=subprocess.DEVNULL,
770
+ )
771
+ log.info("Upgrade command triggered: %s", command)
772
+ return True, None
773
+ except Exception as exc:
774
+ log.error("Failed to trigger upgrade command: %s", exc)
775
+ return False, str(exc)
776
+
777
+
778
+ async def _periodic_update_check(bot: Bot) -> None:
779
+ """The background periodically checks for version updates and notifies the administrator. """
780
+
781
+ await asyncio.sleep(10)
782
+ while True:
783
+ try:
784
+ await _notify_update_to_targets(bot, _collect_admin_targets(), force_check=True)
785
+ except Exception as exc: # pragma: no cover - Use for downtime debugging
786
+ log.error("Automatic version detection failed: %s", exc)
787
+ await asyncio.sleep(int(UPDATE_CHECK_INTERVAL.total_seconds()))
526
788
 
527
789
 
528
790
  def _detect_proxy() -> Tuple[Optional[str], Optional[BasicAuth], Optional[str]]:
529
- """从环境变量解析可用的代理配置。"""
791
+ """Resolve available proxy configurations from environment variables. """
530
792
 
531
793
  candidates = [
532
794
  ("TELEGRAM_PROXY", os.environ.get("TELEGRAM_PROXY")),
@@ -556,14 +818,14 @@ def _detect_proxy() -> Tuple[Optional[str], Optional[BasicAuth], Optional[str]]:
556
818
  if parsed.port:
557
819
  netloc += f":{parsed.port}"
558
820
  proxy_raw = parsed._replace(netloc=netloc, path="", params="", query="", fragment="").geturl()
559
- log.info("使用代理(%s): %s", source, _mask_proxy(proxy_raw))
821
+ log.info("Use proxy(%s): %s", source, _mask_proxy(proxy_raw))
560
822
  return proxy_raw, auth, source
561
823
 
562
824
 
563
825
  def _sanitize_slug(text: str) -> str:
564
- """将任意字符串转换为 project_slug 可用的短标签。"""
826
+ """Convert an arbitrary string into a short tag usable by project_slug. """
565
827
 
566
- slug = text.lower().replace(" ", "-")
828
+ slug=text.lower().replace(" ", "-")
567
829
  slug = slug.replace("/", "-").replace("\\", "-")
568
830
  slug = slug.strip("-")
569
831
  return slug or "project"
@@ -571,7 +833,7 @@ def _sanitize_slug(text: str) -> str:
571
833
 
572
834
  @dataclass
573
835
  class ProjectConfig:
574
- """描述单个项目的静态配置。"""
836
+ """Describes the static configuration of a single project. """
575
837
 
576
838
  bot_name: str
577
839
  bot_token: str
@@ -582,31 +844,31 @@ class ProjectConfig:
582
844
  legacy_name: Optional[str] = None
583
845
 
584
846
  def __post_init__(self) -> None:
585
- """保证 bot 名称合法,去除多余前缀与空白。"""
847
+ """Make sure the bot name is legal and remove redundant prefixes and spaces. """
586
848
 
587
849
  clean_name = self.bot_name.strip()
588
- if clean_name.startswith("@"): # 允许配置中直接写带@
850
+ if clean_name.startswith("@"): # Allow direct writing with @ in the configuration
589
851
  clean_name = clean_name[1:]
590
852
  clean_name = clean_name.strip()
591
853
  if not clean_name:
592
- raise ValueError("bot_name 不能为空")
854
+ raise ValueError("bot_name cannot be empty")
593
855
  self.bot_name = clean_name
594
856
 
595
857
  @property
596
858
  def display_name(self) -> str:
597
- """返回展示用的 bot 名称。"""
859
+ """Returns the bot name used for display. """
598
860
 
599
861
  return self.bot_name
600
862
 
601
863
  @property
602
864
  def jump_url(self) -> str:
603
- """生成跳转到 Telegram Bot 的链接。"""
865
+ """Generate a link to Telegram Bot. """
604
866
 
605
867
  return f"https://t.me/{self.bot_name}"
606
868
 
607
869
  @classmethod
608
870
  def from_dict(cls, data: dict) -> "ProjectConfig":
609
- """ JSON 字典构造 ProjectConfig 实例。"""
871
+ """Constructs a ProjectConfig instance from a JSON dictionary. """
610
872
 
611
873
  raw_bot_name = data.get("bot_name") or data.get("name")
612
874
  if not raw_bot_name:
@@ -630,21 +892,21 @@ class ProjectConfig:
630
892
 
631
893
  @dataclass
632
894
  class ProjectState:
633
- """表示项目当前运行状态,由 StateStore 持久化。"""
895
+ """Represents the current running status of the project, which is persisted by StateStore. """
634
896
 
635
- model: str
897
+ model:str
636
898
  status: str = "stopped"
637
899
  chat_id: Optional[int] = None
638
900
 
639
901
 
640
902
  class StateStore:
641
- """负责维护项目运行状态的文件持久化。"""
903
+ """Responsible for maintaining file persistence of project running status. """
642
904
 
643
905
  def __init__(self, path: Path, configs: Dict[str, ProjectConfig]):
644
- """初始化状态存储,加载已有 state 文件并对缺失项使用默认值。"""
906
+ """Initialize the state store, loading existing state files and using default values ​​for missing items. """
645
907
 
646
908
  self.path = path
647
- self.configs = configs # key 使用 project_slug
909
+ self.configs = configs # key Use project_slug
648
910
  self.data: Dict[str, ProjectState] = {}
649
911
  self.refresh()
650
912
  self.save()
@@ -654,13 +916,13 @@ class StateStore:
654
916
  configs: Dict[str, ProjectConfig],
655
917
  preserve: Optional[Dict[str, ProjectState]] = None,
656
918
  ) -> None:
657
- """更新配置映射,新增项目时写入默认状态,删除项目时移除记录。"""
919
+ """Update the configuration mapping, write the default state when adding a new item, and remove the record when deleting an item. """
658
920
  self.configs = configs
659
- # 移除已删除项目的状态
921
+ # Remove deleted item status
660
922
  for slug in list(self.data.keys()):
661
923
  if slug not in configs:
662
924
  del self.data[slug]
663
- # 为新增项目补充默认状态
925
+ # Supplement the default status for new projects
664
926
  for slug, cfg in configs.items():
665
927
  if slug not in self.data:
666
928
  self.data[slug] = ProjectState(
@@ -673,13 +935,13 @@ class StateStore:
673
935
  self.save()
674
936
 
675
937
  def refresh(self) -> None:
676
- """ state 文件重新加载所有项目状态。"""
938
+ """Reload all project states from state files. """
677
939
 
678
940
  if self.path.exists():
679
941
  try:
680
942
  raw = json.loads(self.path.read_text(encoding="utf-8"))
681
943
  except json.JSONDecodeError:
682
- log.warning("无法解析 state 文件 %s,使用空状态", self.path)
944
+ log.warning("Unable to parse state file %s, using empty state", self.path)
683
945
  raw = {}
684
946
  else:
685
947
  raw = {}
@@ -699,7 +961,7 @@ class StateStore:
699
961
  self.data[slug] = ProjectState(model=model, status=status, chat_id=chat_id_value)
700
962
 
701
963
  def save(self) -> None:
702
- """将当前内存状态写入磁盘文件。"""
964
+ """Write the current memory state to a disk file. """
703
965
 
704
966
  self.path.parent.mkdir(parents=True, exist_ok=True)
705
967
  payload = {
@@ -720,7 +982,7 @@ class StateStore:
720
982
  status: Optional[str] = None,
721
983
  chat_id: Optional[int] = None,
722
984
  ) -> None:
723
- """更新指定项目的状态并立即持久化。"""
985
+ """Updates the state of the specified project and makes it persist immediately. """
724
986
 
725
987
  state = self.data[slug]
726
988
  if model is not None:
@@ -733,10 +995,10 @@ class StateStore:
733
995
 
734
996
 
735
997
  class MasterManager:
736
- """封装项目配置、状态持久化与前置检查等核心逻辑。"""
998
+ """Encapsulates core logic such as project configuration, state persistence, and pre-checking. """
737
999
 
738
1000
  def __init__(self, configs: List[ProjectConfig], *, state_store: StateStore):
739
- """构建 manager,并基于配置建立 slug/mention 索引。"""
1001
+ """Build the manager and build the slug/mention index based on the configuration. """
740
1002
 
741
1003
  self.configs = configs
742
1004
  self._slug_index: Dict[str, ProjectConfig] = {cfg.project_slug: cfg for cfg in configs}
@@ -751,23 +1013,23 @@ class MasterManager:
751
1013
  self.admin_ids = {int(x) for x in admins.split(",") if x.strip().isdigit()}
752
1014
 
753
1015
  def require_project(self, name: str) -> ProjectConfig:
754
- """根据项目名或 @bot 名查找配置,找不到时报错。"""
1016
+ """Search the configuration based on the project name or @bot name, and an error will be reported if it cannot be found. """
755
1017
 
756
1018
  cfg = self._resolve_project(name)
757
1019
  if not cfg:
758
- raise ValueError(f"未知项目 {name}")
1020
+ raise ValueError(f"Unknown item {name}")
759
1021
  return cfg
760
1022
 
761
1023
  def require_project_by_slug(self, slug: str) -> ProjectConfig:
762
- """根据 project_slug 查找配置。"""
1024
+ """Find configuration based on project_slug. """
763
1025
 
764
1026
  cfg = self._slug_index.get(slug)
765
1027
  if not cfg:
766
- raise ValueError(f"未知项目 {slug}")
1028
+ raise ValueError(f"Unknown item {slug}")
767
1029
  return cfg
768
1030
 
769
1031
  def _resolve_project(self, identifier: str) -> Optional[ProjectConfig]:
770
- """ slug/mention 索引中寻找匹配的项目配置。"""
1032
+ """Look for matching project configurations in the slug/mention index. """
771
1033
 
772
1034
  if not identifier:
773
1035
  return None
@@ -778,7 +1040,7 @@ class MasterManager:
778
1040
  return self._slug_index[raw]
779
1041
  if raw in self._mention_index:
780
1042
  return self._mention_index[raw]
781
- if raw.startswith("@"): # 允许用户直接输入 @bot_name
1043
+ if raw.startswith("@"): # Allow users to enter @bot_name directly
782
1044
  stripped = raw[1:]
783
1045
  if stripped in self._mention_index:
784
1046
  return self._mention_index[stripped]
@@ -793,7 +1055,7 @@ class MasterManager:
793
1055
  configs: List[ProjectConfig],
794
1056
  preserve: Optional[Dict[str, ProjectState]] = None,
795
1057
  ) -> None:
796
- """刷新项目配置索引,便于在新增/删除后立即生效。"""
1058
+ """Refresh the project configuration index so that it takes effect immediately after addition/deletion. """
797
1059
  self.configs = configs
798
1060
  self._slug_index = {cfg.project_slug: cfg for cfg in configs}
799
1061
  self._mention_index = {}
@@ -805,23 +1067,23 @@ class MasterManager:
805
1067
  self.state_store.reset_configs({cfg.project_slug: cfg for cfg in configs}, preserve=preserve)
806
1068
 
807
1069
  def refresh_state(self) -> None:
808
- """从磁盘重新加载项目运行状态。"""
1070
+ """Reload project running status from disk. """
809
1071
 
810
1072
  self.state_store.refresh()
811
1073
 
812
1074
  def list_states(self) -> Dict[str, ProjectState]:
813
- """返回当前所有项目的状态字典。"""
1075
+ """Returns a dictionary of statuses for all current projects. """
814
1076
 
815
1077
  return self.state_store.data
816
1078
 
817
1079
  def is_authorized(self, chat_id: int) -> bool:
818
- """检查给定 chat_id 是否在管理员名单中。"""
1080
+ """Checks whether the given chat_id is in the list of administrators. """
819
1081
 
820
1082
  return not self.admin_ids or chat_id in self.admin_ids
821
1083
 
822
1084
  @staticmethod
823
1085
  def _format_issue_message(title: str, issues: Sequence[str]) -> str:
824
- """按照项目自检的结果拼装 Markdown 文本。"""
1086
+ """Assemble Markdown text according to the results of the project self-test. """
825
1087
 
826
1088
  lines: List[str] = []
827
1089
  for issue in issues:
@@ -831,35 +1093,35 @@ class MasterManager:
831
1093
  lines.extend(f" {line}" for line in rest)
832
1094
  else:
833
1095
  lines.append(f"- {issue}")
834
- joined = "\n".join(lines) if lines else "- "
1096
+ joined = "\n".join(lines) if lines else "- None"
835
1097
  return f"{title}\n{joined}"
836
1098
 
837
1099
  def _collect_prerequisite_issues(self, cfg: ProjectConfig, model: str) -> List[str]:
838
- """检查模型启动前的依赖条件,返回所有未满足的项。"""
1100
+ """Check the dependency conditions before starting the model and return all unsatisfied items. """
839
1101
 
840
1102
  issues: List[str] = []
841
1103
  workdir_raw = (cfg.workdir or "").strip()
842
1104
  if not workdir_raw:
843
1105
  issues.append(
844
- "未配置 workdir,请通过项目管理功能为该项目设置工作目录"
1106
+ "The workdir is not configured, please set the working directory for the project through the project management function"
845
1107
  )
846
1108
  expanded_dir = None
847
1109
  else:
848
1110
  expanded = Path(os.path.expandvars(os.path.expanduser(workdir_raw)))
849
1111
  if not expanded.exists():
850
- issues.append(f"工作目录不存在: {workdir_raw}")
1112
+ issues.append(f"Working directory does not exist: {workdir_raw}")
851
1113
  expanded_dir = None
852
1114
  elif not expanded.is_dir():
853
- issues.append(f"工作目录不是文件夹: {workdir_raw}")
1115
+ issues.append(f"Working directory is not a folder: {workdir_raw}")
854
1116
  expanded_dir = None
855
1117
  else:
856
1118
  expanded_dir = expanded
857
1119
 
858
1120
  if not cfg.bot_token:
859
- issues.append("bot_token 未配置,请通过项目管理功能补充该字段")
1121
+ issues.append("bot_token Not configured, please supplement this field through the project management function")
860
1122
 
861
1123
  if shutil.which("tmux") is None:
862
- issues.append("未检测到 tmux,可通过 'brew install tmux' 安装")
1124
+ issues.append("tmux not detected, installable via 'brew install tmux'")
863
1125
 
864
1126
  model_lower = (model or "").lower()
865
1127
  model_cmd = os.environ.get("MODEL_CMD")
@@ -877,13 +1139,13 @@ class MasterManager:
877
1139
  except ValueError:
878
1140
  executable = None
879
1141
  if executable and shutil.which(executable) is None:
880
- issues.append(f"未检测到模型命令 {executable},请确认已安装")
1142
+ issues.append(f"Model command {executable} not detected, please confirm it is installed")
881
1143
  elif model_lower != "gemini":
882
- issues.append("未找到模型命令配置,无法启动 worker")
1144
+ issues.append("Model command configuration not found, unable to start worker")
883
1145
 
884
1146
  if expanded_dir is None and workdir_raw:
885
1147
  log.debug(
886
- "工作目录校验失败",
1148
+ "Working directory verification failed",
887
1149
  extra={"project": cfg.project_slug, "workdir": workdir_raw},
888
1150
  )
889
1151
 
@@ -891,7 +1153,7 @@ class MasterManager:
891
1153
 
892
1154
  @staticmethod
893
1155
  def _pid_alive(pid: int) -> bool:
894
- """检测指定 PID 的进程是否仍在运行。"""
1156
+ """Detects whether the process with the specified PID is still running. """
895
1157
 
896
1158
  try:
897
1159
  os.kill(pid, 0)
@@ -903,7 +1165,7 @@ class MasterManager:
903
1165
  return True
904
1166
 
905
1167
  def _log_tail(self, path: Path, *, lines: int = WORKER_HEALTH_LOG_TAIL) -> str:
906
- """读取日志文件尾部,协助诊断启动失败原因。"""
1168
+ """Read the tail of the log file to help diagnose the cause of startup failure. """
907
1169
 
908
1170
  if not path.exists():
909
1171
  return ""
@@ -912,7 +1174,7 @@ class MasterManager:
912
1174
  data = fh.readlines()
913
1175
  except Exception as exc:
914
1176
  log.warning(
915
- "读取日志失败: %s",
1177
+ "Failed to read log: %s",
916
1178
  exc,
917
1179
  extra={"log_path": str(path)},
918
1180
  )
@@ -923,7 +1185,7 @@ class MasterManager:
923
1185
  return "".join(tail).rstrip()
924
1186
 
925
1187
  def _log_contains_handshake(self, path: Path) -> bool:
926
- """判断日志中是否包含 Telegram 握手成功的标记。"""
1188
+ """Determine whether the log contains a successful Telegram handshake mark. """
927
1189
 
928
1190
  if not path.exists():
929
1191
  return False
@@ -931,7 +1193,7 @@ class MasterManager:
931
1193
  text = path.read_text(encoding="utf-8", errors="ignore")
932
1194
  except Exception as exc:
933
1195
  log.warning(
934
- "读取日志失败: %s",
1196
+ "Failed to read log: %s",
935
1197
  exc,
936
1198
  extra={"log_path": str(path)},
937
1199
  )
@@ -939,9 +1201,9 @@ class MasterManager:
939
1201
  return any(marker in text for marker in HANDSHAKE_MARKERS)
940
1202
 
941
1203
  async def _health_check_worker(self, cfg: ProjectConfig, model: str) -> Optional[str]:
942
- """验证 worker 启动后的健康状态,返回失败描述。"""
1204
+ """Verify the health status of the worker after it is started and return a failure description. """
943
1205
 
944
- log_dir = LOG_ROOT_PATH / model / cfg.project_slug
1206
+ log_dir = LOG_ROOT_PATH/model/cfg.project_slug
945
1207
  pid_path = log_dir / "bot.pid"
946
1208
  run_log = log_dir / "run_bot.log"
947
1209
 
@@ -958,14 +1220,14 @@ class MasterManager:
958
1220
  break
959
1221
  except ValueError:
960
1222
  log.warning(
961
- "pid 文件 %s 内容异常",
1223
+ "pid The content of file %s is abnormal",
962
1224
  str(pid_path),
963
1225
  extra={"content": pid_path.read_text(encoding="utf-8", errors="ignore")},
964
1226
  )
965
1227
  last_seen_pid = None
966
1228
  except Exception as exc:
967
1229
  log.warning(
968
- "读取 pid 文件失败: %s",
1230
+ "Failed to read pid file: %s",
969
1231
  exc,
970
1232
  extra={"pid_path": str(pid_path)},
971
1233
  )
@@ -977,31 +1239,31 @@ class MasterManager:
977
1239
 
978
1240
  issues: List[str] = []
979
1241
  if last_seen_pid is None:
980
- issues.append("未检测到 bot.pid 或内容为空")
1242
+ issues.append("Bot not detected.pid Or the content is empty")
981
1243
  else:
982
1244
  if self._pid_alive(last_seen_pid):
983
1245
  issues.append(
984
- f"worker 进程 {last_seen_pid} 未在 {WORKER_HEALTH_TIMEOUT:.1f}s 内完成 Telegram 握手"
1246
+ f"worker Process {last_seen_pid} is not in {WORKER_HEALTH_TIMEOUT:.1f}s Complete Telegram handshake"
985
1247
  )
986
1248
  else:
987
- issues.append(f"worker 进程 {last_seen_pid} 已退出")
1249
+ issues.append(f"worker Process {last_seen_pid} has exited")
988
1250
 
989
1251
  log_tail = self._log_tail(run_log)
990
1252
  if log_tail:
991
1253
  issues.append(
992
- "最近日志:\n" + textwrap.indent(log_tail, prefix=" ")
1254
+ "Recent logs:\n" + textwrap.indent(log_tail, prefix=" ")
993
1255
  )
994
1256
 
995
1257
  if not issues:
996
1258
  return None
997
1259
 
998
1260
  return self._format_issue_message(
999
- f"{cfg.display_name} 启动失败",
1261
+ f"{cfg.display_name} Startup failed",
1000
1262
  issues,
1001
1263
  )
1002
1264
 
1003
1265
  async def run_worker(self, cfg: ProjectConfig, model: Optional[str] = None) -> str:
1004
- """启动指定项目的 worker,并返回运行模型名称。"""
1266
+ """Starts a worker for the specified project and returns the running model name. """
1005
1267
 
1006
1268
  self.refresh_state()
1007
1269
  state = self.state_store.data[cfg.project_slug]
@@ -1009,11 +1271,11 @@ class MasterManager:
1009
1271
  issues = self._collect_prerequisite_issues(cfg, target_model)
1010
1272
  if issues:
1011
1273
  message = self._format_issue_message(
1012
- f"{cfg.display_name} 启动失败,缺少必要依赖或配置",
1274
+ f"{cfg.display_name} Startup failed, missing necessary dependencies or configuration",
1013
1275
  issues,
1014
1276
  )
1015
1277
  log.error(
1016
- "启动前自检失败: %s",
1278
+ "Pre-start self-test failed: %s",
1017
1279
  message,
1018
1280
  extra={"project": cfg.project_slug, "model": target_model},
1019
1281
  )
@@ -1034,7 +1296,7 @@ class MasterManager:
1034
1296
  )
1035
1297
  cmd = [str(RUN_SCRIPT), "--model", target_model, "--project", cfg.project_slug]
1036
1298
  log.info(
1037
- "启动 worker: %s (model=%s, chat_id=%s)",
1299
+ "Start worker: %s (model=%s, chat_id=%s)",
1038
1300
  cfg.display_name,
1039
1301
  target_model,
1040
1302
  chat_id_env,
@@ -1057,15 +1319,15 @@ class MasterManager:
1057
1319
  combined_output = "".join(output_chunks).strip()
1058
1320
  if rc != 0:
1059
1321
  tail_lines = "\n".join(combined_output.splitlines()[-20:]) if combined_output else ""
1060
- issues = [f"run_bot.sh 退出码 {rc}"]
1322
+ issues = [f"run_bot.sh Exit code {rc}"]
1061
1323
  if tail_lines:
1062
- issues.append("脚本输出:\n " + "\n ".join(tail_lines.splitlines()))
1324
+ issues.append("Script output:\n " + "\n ".join(tail_lines.splitlines()))
1063
1325
  message = self._format_issue_message(
1064
- f"{cfg.display_name} 启动失败",
1326
+ f"{cfg.display_name} Startup failed",
1065
1327
  issues,
1066
1328
  )
1067
1329
  log.error(
1068
- "worker 启动失败: %s",
1330
+ "worker Startup failed: %s",
1069
1331
  message,
1070
1332
  extra={"project": cfg.project_slug, "model": target_model},
1071
1333
  )
@@ -1074,7 +1336,7 @@ class MasterManager:
1074
1336
  if health_issue:
1075
1337
  self.state_store.update(cfg.project_slug, status="stopped")
1076
1338
  log.error(
1077
- "worker 健康检查失败: %s",
1339
+ "worker Health check failed: %s",
1078
1340
  health_issue,
1079
1341
  extra={"project": cfg.project_slug, "model": target_model},
1080
1342
  )
@@ -1084,7 +1346,7 @@ class MasterManager:
1084
1346
  return target_model
1085
1347
 
1086
1348
  async def stop_worker(self, cfg: ProjectConfig, *, update_state: bool = True) -> None:
1087
- """停止指定项目的 worker,必要时刷新状态。"""
1349
+ """Stops the worker for the specified project and refreshes the status if necessary. """
1088
1350
 
1089
1351
  self.refresh_state()
1090
1352
  state = self.state_store.data[cfg.project_slug]
@@ -1094,24 +1356,24 @@ class MasterManager:
1094
1356
  await proc.wait()
1095
1357
  if update_state:
1096
1358
  self.state_store.update(cfg.project_slug, status="stopped")
1097
- log.info("已停止 worker: %s", cfg.display_name, extra={"project": cfg.project_slug})
1359
+ log.info("Stopped worker: %s", cfg.display_name, extra={"project": cfg.project_slug})
1098
1360
 
1099
1361
  async def stop_all(self, *, update_state: bool = False) -> None:
1100
- """依次停止所有项目的 worker。"""
1362
+ """Stop workers for all projects in sequence. """
1101
1363
 
1102
1364
  for cfg in self.configs:
1103
1365
  try:
1104
1366
  await self.stop_worker(cfg, update_state=update_state)
1105
1367
  except Exception as exc:
1106
1368
  log.warning(
1107
- "停止 %s 时出错: %s",
1369
+ "Error stopping %s: %s",
1108
1370
  cfg.display_name,
1109
1371
  exc,
1110
1372
  extra={"project": cfg.project_slug},
1111
1373
  )
1112
1374
 
1113
1375
  async def run_all(self) -> None:
1114
- """启动所有尚未运行的项目 worker。"""
1376
+ """Start all project workers that are not already running. """
1115
1377
 
1116
1378
  self.refresh_state()
1117
1379
  errors: List[str] = []
@@ -1123,7 +1385,7 @@ class MasterManager:
1123
1385
  await self.run_worker(cfg)
1124
1386
  except Exception as exc:
1125
1387
  log.warning(
1126
- "启动 %s 时出错: %s",
1388
+ "Error starting %s: %s",
1127
1389
  cfg.display_name,
1128
1390
  exc,
1129
1391
  extra={"project": cfg.project_slug},
@@ -1131,24 +1393,24 @@ class MasterManager:
1131
1393
  errors.append(f"{cfg.display_name}: {exc}")
1132
1394
  if errors:
1133
1395
  raise RuntimeError(
1134
- self._format_issue_message("部分项目启动失败", errors)
1396
+ self._format_issue_message("Some projects failed to start", errors)
1135
1397
  )
1136
1398
 
1137
1399
  async def restore_running(self) -> None:
1138
- """根据 state 文件恢复上一轮仍在运行的 worker。"""
1400
+ """Resume the workers that were still running in the previous round according to the state file. """
1139
1401
 
1140
1402
  self.refresh_state()
1141
1403
  for slug, state in self.state_store.data.items():
1142
1404
  if state.status == "running":
1143
1405
  cfg = self._slug_index.get(slug)
1144
1406
  if not cfg:
1145
- log.warning("状态文件包含未知项目: %s", slug)
1407
+ log.warning("Status file contains unknown item: %s", slug)
1146
1408
  continue
1147
1409
  try:
1148
1410
  await self.run_worker(cfg, model=state.model)
1149
1411
  except Exception as exc:
1150
1412
  log.error(
1151
- "恢复 %s 失败: %s",
1413
+ "Restore %s failed: %s",
1152
1414
  cfg.display_name,
1153
1415
  exc,
1154
1416
  extra={"project": cfg.project_slug, "model": state.model},
@@ -1156,16 +1418,16 @@ class MasterManager:
1156
1418
  self.state_store.update(slug, status="stopped")
1157
1419
 
1158
1420
  def update_chat_id(self, slug: str, chat_id: int) -> None:
1159
- """记录或更新项目的 chat_id 绑定信息。"""
1421
+ """Record or update the chat_id binding information of the project. """
1160
1422
 
1161
1423
  cfg = self._resolve_project(slug)
1162
1424
  if not cfg:
1163
- raise ValueError(f"未知项目 {slug}")
1425
+ raise ValueError(f"Unknown item {slug}")
1164
1426
  self.state_store.update(cfg.project_slug, chat_id=chat_id)
1165
1427
  log.info(
1166
- "记录 %s chat_id=%s",
1167
- cfg.display_name,
1428
+ "Recorded chat_id=%s for %s",
1168
1429
  chat_id,
1430
+ cfg.display_name,
1169
1431
  extra={"project": cfg.project_slug},
1170
1432
  )
1171
1433
 
@@ -1177,7 +1439,7 @@ ProjectField = Literal["bot_name", "bot_token", "project_slug", "default_model",
1177
1439
 
1178
1440
  @dataclass
1179
1441
  class ProjectWizardSession:
1180
- """记录单个聊天的项目管理对话状态。"""
1442
+ """Record project management conversation status for a single chat. """
1181
1443
 
1182
1444
  chat_id: int
1183
1445
  user_id: int
@@ -1208,27 +1470,27 @@ PROJECT_MODEL_CHOICES: Tuple[str, ...] = ("codex", "claudecode", "gemini")
1208
1470
  PROJECT_WIZARD_SESSIONS: Dict[int, ProjectWizardSession] = {}
1209
1471
  PROJECT_WIZARD_LOCK = asyncio.Lock()
1210
1472
  PROJECT_FIELD_PROMPTS_CREATE: Dict[ProjectField, str] = {
1211
- "bot_name": "请输入 bot 名称(不含 @,仅字母、数字、下划线或点):",
1212
- "bot_token": "请输入 Telegram Bot Token(格式类似 123456:ABCdef):",
1213
- "project_slug": "请输入项目 slug(用于日志目录,留空自动根据 bot 名生成):",
1214
- "default_model": "请输入默认模型(codex/claudecode/gemini,留空采用 codex):",
1215
- "workdir": "请输入 worker 工作目录绝对路径(可留空稍后补全):",
1216
- "allowed_chat_id": "请输入预设 chat_id(可留空,暂不支持多个):",
1473
+ "bot_name": "Please enter a bot name (without @, only letters, numbers, underscores or dots):",
1474
+ "bot_token": "Please enter Telegram Bot Token (format similar to 123456:ABCdef):",
1475
+ "project_slug": "Please enter the project slug (for the log directory, leave it blank to automatically generate based on the bot name): ",
1476
+ "default_model": "Please enter the default model (codex/claudecode/gemini, leave it blank to use codex):",
1477
+ "workdir": "Please enter the absolute path of the worker's working directory (you can leave it blank and complete it later): ",
1478
+ "allowed_chat_id": "Please enter the default chat_id (can be left blank, multiple are not supported at the moment): ",
1217
1479
  }
1218
1480
  PROJECT_FIELD_PROMPTS_EDIT: Dict[ProjectField, str] = {
1219
- "bot_name": "请输入新的 bot 名(不含 @,发送 - 保持当前值:{current}):",
1220
- "bot_token": "请输入新的 Bot Token(发送 - 保持当前值):",
1221
- "project_slug": "请输入新的项目 slug(发送 - 保持当前值:{current}):",
1222
- "default_model": "请输入新的默认模型(codex/claudecode/gemini,发送 - 保持当前值:{current}):",
1223
- "workdir": "请输入新的工作目录(发送 - 保持当前值:{current},可留空改为未设置):",
1224
- "allowed_chat_id": "请输入新的 chat_id(发送 - 保持当前值:{current},留空表示取消预设):",
1481
+ "bot_name": "Please enter a new bot name (without @, send - keep current value: {current}):",
1482
+ "bot_token": "Please enter new Bot Token (send - keep current value):",
1483
+ "project_slug": "Please enter new item slug (send - keep current value: {current}):",
1484
+ "default_model": "Please enter new default model (codex/claudecode/gemini, send - keep current value: {current}):",
1485
+ "workdir": "Please enter a new working directory (send - keep current value: {current}, can be left blank to not set): ",
1486
+ "allowed_chat_id": "Please enter a new chat_id (Send - keep current value: {current}, leave blank to cancel default):",
1225
1487
  }
1226
1488
 
1227
1489
 
1228
1490
  def _ensure_repository() -> ProjectRepository:
1229
- """获取项目仓库实例,未初始化时抛出异常。"""
1491
+ """Get the project warehouse instance and throw an exception if it is not initialized. """
1230
1492
  if PROJECT_REPOSITORY is None:
1231
- raise RuntimeError("项目仓库未初始化")
1493
+ raise RuntimeError("Project warehouse is not initialized")
1232
1494
  return PROJECT_REPOSITORY
1233
1495
 
1234
1496
 
@@ -1237,7 +1499,7 @@ def _reload_manager_configs(
1237
1499
  *,
1238
1500
  preserve: Optional[Dict[str, ProjectState]] = None,
1239
1501
  ) -> List[ProjectConfig]:
1240
- """重新加载项目配置,并可选地保留指定状态映射。"""
1502
+ """Reloads the project configuration and optionally preserves the specified state mapping. """
1241
1503
  repository = _ensure_repository()
1242
1504
  records = repository.list_projects()
1243
1505
  configs = [ProjectConfig.from_dict(record.to_dict()) for record in records]
@@ -1250,10 +1512,10 @@ def _validate_field_value(
1250
1512
  field_name: ProjectField,
1251
1513
  raw_text: str,
1252
1514
  ) -> Tuple[Optional[Any], Optional[str]]:
1253
- """校验字段输入,返回转换后的值与错误信息。"""
1515
+ """Verify field input and return the converted value and error message. """
1254
1516
  text = raw_text.strip()
1255
1517
  repository = _ensure_repository()
1256
- # 编辑流程允许使用 "-" 保持原值
1518
+ # The editing process allows the use of "-" to maintain the original value
1257
1519
  if session.mode == "edit" and text == "-":
1258
1520
  return session.data.get(field_name), None
1259
1521
 
@@ -1263,44 +1525,44 @@ def _validate_field_value(
1263
1525
  if field_name == "bot_name":
1264
1526
  candidate = text.lstrip("@").strip()
1265
1527
  if not candidate:
1266
- return None, "bot 名不能为空"
1528
+ return None, "bot name cannot be empty"
1267
1529
  if not re.fullmatch(r"[A-Za-z0-9_.]{5,64}", candidate):
1268
- return None, "bot 名仅允许 5-64 位字母、数字、下划线或点"
1530
+ return None, "bot Names only allow 5-64 letters, numbers, underscores or dots"
1269
1531
  existing = repository.get_by_bot_name(candidate)
1270
1532
  if existing and (session.mode == "create" or existing.project_slug != session.original_slug):
1271
- return None, " bot 名已被其它项目占用"
1533
+ return None, "The bot name is already occupied by another project"
1272
1534
  return candidate, None
1273
1535
 
1274
1536
  if field_name == "bot_token":
1275
1537
  if not re.fullmatch(r"\d+:[A-Za-z0-9_-]{20,128}", text):
1276
- return None, "Bot Token 格式不正确,请确认输入"
1538
+ return None, "Bot Token The format is incorrect, please confirm your input"
1277
1539
  return text, None
1278
1540
 
1279
1541
  if field_name == "project_slug":
1280
1542
  candidate = _sanitize_slug(text or session.data.get("bot_name", ""))
1281
1543
  if not candidate:
1282
- return None, "无法生成有效的 slug,请重新输入"
1544
+ return None, "Unable to generate a valid slug, please re-enter"
1283
1545
  existing = repository.get_by_slug(candidate)
1284
1546
  if existing and (session.mode == "create" or existing.project_slug != session.original_slug):
1285
- return None, " slug 已存在,请更换其它名称"
1547
+ return None, "The slug already exists, please change it to another name"
1286
1548
  return candidate, None
1287
1549
 
1288
1550
  if field_name == "default_model":
1289
1551
  candidate = text.lower() if text else "codex"
1290
1552
  if candidate not in PROJECT_MODEL_CHOICES:
1291
- return None, f"默认模型仅支持 {', '.join(PROJECT_MODEL_CHOICES)}"
1553
+ return None, f"The default model only supports {', '.join(PROJECT_MODEL_CHOICES)}"
1292
1554
  return candidate, None
1293
1555
 
1294
1556
  if field_name == "workdir":
1295
1557
  expanded = os.path.expandvars(os.path.expanduser(text))
1296
1558
  path = Path(expanded)
1297
1559
  if not path.exists() or not path.is_dir():
1298
- return None, f"目录不存在或不可用:{text}"
1560
+ return None, f"Directory does not exist or is unavailable: {text}"
1299
1561
  return str(path), None
1300
1562
 
1301
1563
  if field_name == "allowed_chat_id":
1302
1564
  if not re.fullmatch(r"-?\d+", text):
1303
- return None, "chat_id 需为整数,可留空跳过"
1565
+ return None, "chat_id It needs to be an integer and can be left blank to skip"
1304
1566
  return int(text), None
1305
1567
 
1306
1568
  return text, None
@@ -1309,12 +1571,12 @@ def _validate_field_value(
1309
1571
  def _format_field_prompt(
1310
1572
  session: ProjectWizardSession, field_name: ProjectField
1311
1573
  ) -> Tuple[str, Optional[InlineKeyboardMarkup]]:
1312
- """根据流程生成字段提示语与可选操作键盘。"""
1574
+ """Generate field prompts and optional operation keyboard according to the process. """
1313
1575
 
1314
1576
  if session.mode == "edit":
1315
1577
  current_value = session.data.get(field_name)
1316
1578
  if current_value is None:
1317
- display = "未设置"
1579
+ display = "Not set"
1318
1580
  elif field_name == "bot_token":
1319
1581
  display = f"{str(current_value)[:6]}***"
1320
1582
  else:
@@ -1334,7 +1596,7 @@ def _format_field_prompt(
1334
1596
  if skip_enabled:
1335
1597
  builder = InlineKeyboardBuilder()
1336
1598
  builder.button(
1337
- text="跳过此项",
1599
+ text="Skip this",
1338
1600
  callback_data=f"project:wizard:skip:{field_name}",
1339
1601
  )
1340
1602
  markup = builder.as_markup()
@@ -1349,7 +1611,7 @@ async def _send_field_prompt(
1349
1611
  *,
1350
1612
  prefix: str = "",
1351
1613
  ) -> None:
1352
- """向用户发送当前字段的提示语与可选跳过按钮。"""
1614
+ """Sends the user a prompt and optional skip button for the current field. """
1353
1615
 
1354
1616
  prompt, markup = _format_field_prompt(session, field_name)
1355
1617
  if prefix:
@@ -1360,7 +1622,7 @@ async def _send_field_prompt(
1360
1622
 
1361
1623
 
1362
1624
  def _session_to_record(session: ProjectWizardSession) -> ProjectRecord:
1363
- """将会话数据转换为 ProjectRecord,编辑时保留 legacy_name"""
1625
+ """Convert session data to ProjectRecord, preserving legacy_name when editing. """
1364
1626
  legacy_name = session.original_record.legacy_name if session.original_record else None
1365
1627
  return ProjectRecord(
1366
1628
  bot_name=session.data["bot_name"],
@@ -1378,14 +1640,14 @@ async def _commit_wizard_session(
1378
1640
  manager: MasterManager,
1379
1641
  message: Message,
1380
1642
  ) -> bool:
1381
- """提交会话数据并执行仓库写入。"""
1643
+ """Commits session data and performs warehouse writes. """
1382
1644
  repository = _ensure_repository()
1383
1645
  record = _session_to_record(session)
1384
1646
  try:
1385
1647
  if session.mode == "create":
1386
1648
  repository.insert_project(record)
1387
1649
  _reload_manager_configs(manager)
1388
- summary_prefix = "新增项目成功 ✅"
1650
+ summary_prefix = "Added project successfully ✅"
1389
1651
  elif session.mode == "edit":
1390
1652
  original_slug = session.original_slug or record.project_slug
1391
1653
  preserve: Optional[Dict[str, ProjectState]] = None
@@ -1396,23 +1658,23 @@ async def _commit_wizard_session(
1396
1658
  if original_slug != record.project_slug and original_slug in manager.state_store.data:
1397
1659
  del manager.state_store.data[original_slug]
1398
1660
  _reload_manager_configs(manager, preserve=preserve)
1399
- summary_prefix = "项目已更新 ✅"
1661
+ summary_prefix = "Project has been updated ✅"
1400
1662
  else:
1401
1663
  return False
1402
1664
  except Exception as exc:
1403
- log.error("项目写入失败: %s", exc, extra={"mode": session.mode})
1404
- await message.answer(f"保存失败:{exc}")
1665
+ log.error("Project write failed: %s", exc, extra={"mode": session.mode})
1666
+ await message.answer(f"Save failed: {exc}")
1405
1667
  return False
1406
1668
 
1407
- workdir_desc = record.workdir or "未设置"
1408
- chat_desc = record.allowed_chat_id if record.allowed_chat_id is not None else "未设置"
1669
+ workdir_desc=record.workdir or "Not set"
1670
+ chat_desc=record.allowed_chat_id if record.allowed_chat_id is not None else "Not set"
1409
1671
  summary = (
1410
1672
  f"{summary_prefix}\n"
1411
- f"bot:@{record.bot_name}\n"
1412
- f"slug{record.project_slug}\n"
1413
- f"模型:{record.default_model}\n"
1414
- f"工作目录:{workdir_desc}\n"
1415
- f"chat_id{chat_desc}"
1673
+ f"bot:@{record.bot_name}\n"
1674
+ f"slug:{record.project_slug}\n"
1675
+ f"Model: {record.default_model}\n"
1676
+ f"Working directory: {workdir_desc}\n"
1677
+ f"chat_id:{chat_desc}"
1416
1678
  )
1417
1679
  await message.answer(summary)
1418
1680
  await _send_projects_overview_to_chat(message.bot, message.chat.id, manager)
@@ -1425,16 +1687,16 @@ async def _advance_wizard_session(
1425
1687
  message: Message,
1426
1688
  text: str,
1427
1689
  *,
1428
- prefix: str = "已记录 ✅",
1690
+ prefix: str = "Recorded ✅",
1429
1691
  ) -> bool:
1430
- """推进项目管理流程,校验输入并触发后续步骤。"""
1692
+ """Advance the project management process, validate inputs and trigger subsequent steps. """
1431
1693
 
1432
1694
  if session.step_index >= len(session.fields):
1433
- await message.answer("流程已完成,如需再次修改请重新开始。")
1695
+ await message.answer("The process has been completed. If you need to modify it again, please start again. ")
1434
1696
  return True
1435
1697
 
1436
1698
  if not session.fields:
1437
- await message.answer("流程配置异常,请重新开始。")
1699
+ await message.answer("The process configuration is abnormal, please start again. ")
1438
1700
  async with PROJECT_WIZARD_LOCK:
1439
1701
  PROJECT_WIZARD_SESSIONS.pop(message.chat.id, None)
1440
1702
  return True
@@ -1442,7 +1704,7 @@ async def _advance_wizard_session(
1442
1704
  field_name = session.fields[session.step_index]
1443
1705
  value, error = _validate_field_value(session, field_name, text)
1444
1706
  if error:
1445
- await message.answer(f"{error}\n请重新输入:")
1707
+ await message.answer(f"{error}\nPlease re-enter:")
1446
1708
  return True
1447
1709
 
1448
1710
  session.data[field_name] = value
@@ -1463,25 +1725,28 @@ async def _advance_wizard_session(
1463
1725
  await _send_field_prompt(session, next_field, message, prefix=prefix)
1464
1726
  return True
1465
1727
 
1466
- # 所有字段已填写,执行写入
1728
+ # All fields are filled in, perform writing
1467
1729
  success = await _commit_wizard_session(session, manager, message)
1468
1730
  async with PROJECT_WIZARD_LOCK:
1469
1731
  PROJECT_WIZARD_SESSIONS.pop(message.chat.id, None)
1470
1732
 
1471
1733
  if success:
1472
- await message.answer("项目管理流程已完成。")
1734
+ await message.answer("The project management process is complete. ")
1473
1735
  return True
1474
1736
 
1475
1737
 
1476
1738
  async def _start_project_create(callback: CallbackQuery, manager: MasterManager) -> None:
1477
- """启动新增项目流程。"""
1739
+ """Start the new project process. """
1478
1740
  if callback.message is None or callback.from_user is None:
1479
1741
  return
1480
1742
  chat_id = callback.message.chat.id
1481
1743
  user_id = callback.from_user.id
1482
1744
  async with PROJECT_WIZARD_LOCK:
1483
1745
  if chat_id in PROJECT_WIZARD_SESSIONS:
1484
- await callback.answer("当前会话已有流程进行中,请先完成或发送“取消”。", show_alert=True)
1746
+ await callback.answer(
1747
+ 'There is already a project wizard in progress for this chat. Complete it first or send "Cancel".',
1748
+ show_alert=True,
1749
+ )
1485
1750
  return
1486
1751
  session = ProjectWizardSession(
1487
1752
  chat_id=chat_id,
@@ -1490,9 +1755,9 @@ async def _start_project_create(callback: CallbackQuery, manager: MasterManager)
1490
1755
  fields=PROJECT_WIZARD_FIELDS_CREATE,
1491
1756
  )
1492
1757
  PROJECT_WIZARD_SESSIONS[chat_id] = session
1493
- await callback.answer("开始新增项目流程")
1758
+ await callback.answer("Started the new-project wizard.")
1494
1759
  await callback.message.answer(
1495
- "已进入新增项目流程,随时可发送“取消”终止。",
1760
+ 'The new-project wizard is now active. Send "Cancel" at any time to abort.',
1496
1761
  )
1497
1762
  first_field = session.fields[0]
1498
1763
  await _send_field_prompt(session, first_field, callback.message)
@@ -1503,19 +1768,22 @@ async def _start_project_edit(
1503
1768
  cfg: ProjectConfig,
1504
1769
  manager: MasterManager,
1505
1770
  ) -> None:
1506
- """启动项目编辑流程。"""
1771
+ """Start the project editing process. """
1507
1772
  if callback.message is None or callback.from_user is None:
1508
1773
  return
1509
1774
  repository = _ensure_repository()
1510
1775
  record = repository.get_by_slug(cfg.project_slug)
1511
1776
  if record is None:
1512
- await callback.answer("未找到项目配置", show_alert=True)
1777
+ await callback.answer("Project configuration not found", show_alert=True)
1513
1778
  return
1514
1779
  chat_id = callback.message.chat.id
1515
1780
  user_id = callback.from_user.id
1516
1781
  async with PROJECT_WIZARD_LOCK:
1517
1782
  if chat_id in PROJECT_WIZARD_SESSIONS:
1518
- await callback.answer("当前会话已有流程进行中,请先完成或发送“取消”。", show_alert=True)
1783
+ await callback.answer(
1784
+ 'There is already a project wizard in progress for this chat. Complete it first or send "Cancel".',
1785
+ show_alert=True,
1786
+ )
1519
1787
  return
1520
1788
  session = ProjectWizardSession(
1521
1789
  chat_id=chat_id,
@@ -1534,26 +1802,26 @@ async def _start_project_edit(
1534
1802
  "allowed_chat_id": record.allowed_chat_id,
1535
1803
  }
1536
1804
  PROJECT_WIZARD_SESSIONS[chat_id] = session
1537
- await callback.answer("开始编辑项目")
1805
+ await callback.answer("Started the edit-project wizard.")
1538
1806
  await callback.message.answer(
1539
- f"已进入编辑流程:{cfg.display_name},随时可发送“取消”终止。",
1807
+ f'Entered the editing wizard for {cfg.display_name}. Send "Cancel" at any time to abort.',
1540
1808
  )
1541
1809
  field_name = session.fields[0]
1542
1810
  await _send_field_prompt(session, field_name, callback.message)
1543
1811
 
1544
1812
 
1545
1813
  def _build_delete_confirmation_keyboard(slug: str) -> InlineKeyboardMarkup:
1546
- """构建删除确认用的按钮键盘。"""
1814
+ """Build a button keyboard for deletion confirmation. """
1547
1815
  builder = InlineKeyboardBuilder()
1548
1816
  builder.row(
1549
1817
  InlineKeyboardButton(
1550
- text="确认删除 ✅",
1818
+ text="Confirm deletion ✅",
1551
1819
  callback_data=f"project:delete_confirm:{slug}",
1552
1820
  )
1553
1821
  )
1554
1822
  builder.row(
1555
1823
  InlineKeyboardButton(
1556
- text="取消",
1824
+ text="Cancel",
1557
1825
  callback_data="project:delete_cancel",
1558
1826
  )
1559
1827
  )
@@ -1567,23 +1835,23 @@ async def _start_project_delete(
1567
1835
  manager: MasterManager,
1568
1836
  state: FSMContext,
1569
1837
  ) -> None:
1570
- """启动删除项目的确认流程。"""
1838
+ """Initiates the confirmation process for deleting the item. """
1571
1839
  if callback.message is None or callback.from_user is None:
1572
1840
  return
1573
1841
  repository = _ensure_repository()
1574
1842
  original_record = repository.get_by_slug(cfg.project_slug)
1575
1843
  original_slug = original_record.project_slug if original_record else cfg.project_slug
1576
- # 删除前再次读取运行态,避免 FSM 上下文被误覆盖
1844
+ # Read the running state again before deleting to avoid accidentally overwriting the FSM context.
1577
1845
  project_runtime_state = _get_project_runtime_state(manager, cfg.project_slug)
1578
1846
  if project_runtime_state and project_runtime_state.status == "running":
1579
- await callback.answer("请先停止该项目的 worker 后再删除。", show_alert=True)
1847
+ await callback.answer("Please stop the worker of this project before deleting it.", show_alert=True)
1580
1848
  return
1581
1849
  current_state = await state.get_state()
1582
1850
  if current_state == ProjectDeleteStates.confirming.state:
1583
1851
  data = await state.get_data()
1584
1852
  existing_slug = str(data.get("project_slug", "")).lower()
1585
1853
  if existing_slug == cfg.project_slug.lower():
1586
- await callback.answer("当前删除流程已在确认中,请使用按钮完成操作。", show_alert=True)
1854
+ await callback.answer("The current deletion process is being confirmed, please use the buttons to finish the operation.", show_alert=True)
1587
1855
  return
1588
1856
  await state.clear()
1589
1857
  await state.set_state(ProjectDeleteStates.confirming)
@@ -1596,10 +1864,10 @@ async def _start_project_delete(
1596
1864
  bot_name=cfg.bot_name,
1597
1865
  )
1598
1866
  markup = _build_delete_confirmation_keyboard(cfg.project_slug)
1599
- await callback.answer("删除确认已发送")
1867
+ await callback.answer("Deletion confirmation sent")
1600
1868
  await callback.message.answer(
1601
- f"确认删除项目 {cfg.display_name}?此操作不可恢复。\n"
1602
- f"请在 {DELETE_CONFIRM_TIMEOUT} 秒内使用下方按钮确认或取消。",
1869
+ f"Confirm deletion of project {cfg.display_name}? This action cannot be undone.\n"
1870
+ f"Use the buttons below to confirm or cancel within {DELETE_CONFIRM_TIMEOUT} seconds.",
1603
1871
  reply_markup=markup,
1604
1872
  )
1605
1873
 
@@ -1608,7 +1876,7 @@ async def _handle_wizard_message(
1608
1876
  message: Message,
1609
1877
  manager: MasterManager,
1610
1878
  ) -> bool:
1611
- """处理项目管理流程中的用户输入。"""
1879
+ """Handle user input in the project management process. """
1612
1880
  if message.chat is None or message.from_user is None:
1613
1881
  return False
1614
1882
  chat_id = message.chat.id
@@ -1617,26 +1885,26 @@ async def _handle_wizard_message(
1617
1885
  if session is None:
1618
1886
  return False
1619
1887
  if message.from_user.id != session.user_id:
1620
- await message.answer("仅流程发起者可以继续操作。")
1888
+ await message.answer("Only the process initiator can proceed. ")
1621
1889
  return True
1622
1890
  text = (message.text or "").strip()
1623
- if text.lower() in {"取消", "cancel", "/cancel"}:
1891
+ if text.lower() in {"Cancel", "cancel", "/cancel"}:
1624
1892
  async with PROJECT_WIZARD_LOCK:
1625
1893
  PROJECT_WIZARD_SESSIONS.pop(chat_id, None)
1626
- await message.answer("已取消项目管理流程。")
1894
+ await message.answer("The project management process has been cancelled. ")
1627
1895
  return True
1628
1896
 
1629
1897
  return await _advance_wizard_session(session, manager, message, text)
1630
1898
  router = Router()
1631
1899
  log = create_logger("master", level_env="MASTER_LOG_LEVEL", stderr_env="MASTER_STDERR")
1632
1900
 
1633
- # 重启状态锁与标记,避免重复触发
1901
+ # Restart state locks and markers to avoid repeated triggering
1634
1902
  _restart_lock: Optional[asyncio.Lock] = None
1635
1903
  _restart_in_progress: bool = False
1636
1904
 
1637
1905
 
1638
1906
  def _ensure_restart_lock() -> asyncio.Lock:
1639
- """延迟创建 asyncio.Lock,确保在事件循环内初始化"""
1907
+ """Lazily create the restart lock, ensuring it is initialised inside the event loop."""
1640
1908
  global _restart_lock
1641
1909
  if _restart_lock is None:
1642
1910
  _restart_lock = asyncio.Lock()
@@ -1644,7 +1912,7 @@ def _ensure_restart_lock() -> asyncio.Lock:
1644
1912
 
1645
1913
 
1646
1914
  def _log_update(message: Message, *, override_user: Optional[User] = None) -> None:
1647
- """记录每条更新并同步 MASTER_ENV_FILE 中的最近聊天信息。"""
1915
+ """Log every update and sync recent chat messages in MASTER_ENV_FILE. """
1648
1916
 
1649
1917
  user = override_user or message.from_user
1650
1918
  username = user.username if user and user.username else None
@@ -1661,45 +1929,45 @@ def _log_update(message: Message, *, override_user: Optional[User] = None) -> No
1661
1929
 
1662
1930
 
1663
1931
  def _safe_remove(path: Path, *, retries: int = 3) -> None:
1664
- """安全移除文件,支持重试机制
1932
+ """Safely remove files and support retry mechanism
1665
1933
 
1666
1934
  Args:
1667
- path: 要删除的文件路径
1668
- retries: 最大重试次数(默认 3 次)
1935
+ path: the path of the file to be deleted
1936
+ retries: Maximum number of retries (default 3)
1669
1937
  """
1670
1938
  if not path.exists():
1671
- log.debug("文件不存在,无需删除", extra={"path": str(path)})
1939
+ log.debug("The file does not exist, no need to delete it", extra={"path": str(path)})
1672
1940
  return
1673
1941
 
1674
1942
  for attempt in range(retries):
1675
1943
  try:
1676
1944
  path.unlink()
1677
- log.info("重启信号文件已删除", extra={"path": str(path), "attempt": attempt + 1})
1945
+ log.info("The restart signal file has been deleted", extra={"path": str(path), "attempt": attempt + 1})
1678
1946
  return
1679
1947
  except FileNotFoundError:
1680
- log.debug("文件已被其他进程删除", extra={"path": str(path)})
1948
+ log.debug("The file has been deleted by another process", extra={"path": str(path)})
1681
1949
  return
1682
1950
  except Exception as exc:
1683
1951
  if attempt < retries - 1:
1684
1952
  log.warning(
1685
- "删除文件失败,将重试 (attempt %d/%d): %s",
1953
+ "Failed to delete file, will try again (attempt %d/%d): %s",
1686
1954
  attempt + 1,
1687
1955
  retries,
1688
1956
  exc,
1689
1957
  extra={"path": str(path)}
1690
1958
  )
1691
1959
  import time
1692
- time.sleep(0.1) # 等待 100ms 后重试
1960
+ time.sleep(0.1) # Wait 100ms and try again
1693
1961
  else:
1694
1962
  log.error(
1695
- "删除文件失败,已达最大重试次数: %s",
1963
+ "Failed to delete file, maximum number of retries reached: %s",
1696
1964
  exc,
1697
1965
  extra={"path": str(path), "retries": retries}
1698
1966
  )
1699
1967
 
1700
1968
 
1701
1969
  def _write_restart_signal(message: Message, *, override_user: Optional[User] = None) -> None:
1702
- """将重启请求信息写入 signal 文件,供新 master 启动后读取"""
1970
+ """Write the restart request information to the signal file for the new master to read after starting """
1703
1971
  now_local = datetime.now(LOCAL_TZ)
1704
1972
  actor = override_user or message.from_user
1705
1973
  payload = {
@@ -1716,7 +1984,7 @@ def _write_restart_signal(message: Message, *, override_user: Optional[User] = N
1716
1984
  )
1717
1985
  tmp_path.replace(RESTART_SIGNAL_PATH)
1718
1986
  log.info(
1719
- "已记录重启信号: chat_id=%s user_id=%s 文件=%s",
1987
+ "Logged restart signal: chat_id=%s user_id=%s file=%s",
1720
1988
  payload["chat_id"],
1721
1989
  payload["user_id"],
1722
1990
  RESTART_SIGNAL_PATH,
@@ -1725,7 +1993,7 @@ def _write_restart_signal(message: Message, *, override_user: Optional[User] = N
1725
1993
 
1726
1994
 
1727
1995
  def _read_restart_signal() -> Tuple[Optional[dict], Optional[Path]]:
1728
- """读取并验证重启 signal,兼容历史路径并处理异常/超时情况"""
1996
+ """Read and verify restart signals, be compatible with historical paths and handle exceptions/timeouts"""
1729
1997
  candidates: Tuple[Path, ...] = (RESTART_SIGNAL_PATH, *LEGACY_RESTART_SIGNAL_PATHS)
1730
1998
  for path in candidates:
1731
1999
  if not path.exists():
@@ -1733,9 +2001,9 @@ def _read_restart_signal() -> Tuple[Optional[dict], Optional[Path]]:
1733
2001
  try:
1734
2002
  raw = json.loads(path.read_text(encoding="utf-8"))
1735
2003
  if not isinstance(raw, dict):
1736
- raise ValueError("signal payload 必须是对象")
2004
+ raise ValueError("signal payload Must be an object")
1737
2005
  except Exception as exc:
1738
- log.error("读取重启信号失败: %s", exc, extra={"path": str(path)})
2006
+ log.error("Failed to read restart signal: %s", exc, extra={"path": str(path)})
1739
2007
  _safe_remove(path)
1740
2008
  continue
1741
2009
 
@@ -1749,7 +2017,7 @@ def _read_restart_signal() -> Tuple[Optional[dict], Optional[Path]]:
1749
2017
  age_seconds = (datetime.now(timezone.utc) - ts_utc).total_seconds()
1750
2018
  if age_seconds > RESTART_SIGNAL_TTL:
1751
2019
  log.info(
1752
- "重启信号超时,忽略",
2020
+ "Restart signal timed out, ignored",
1753
2021
  extra={
1754
2022
  "path": str(path),
1755
2023
  "age_seconds": age_seconds,
@@ -1759,11 +2027,11 @@ def _read_restart_signal() -> Tuple[Optional[dict], Optional[Path]]:
1759
2027
  _safe_remove(path)
1760
2028
  continue
1761
2029
  except Exception as exc:
1762
- log.warning("解析重启信号时间戳失败: %s", exc, extra={"path": str(path)})
2030
+ log.warning("Failed to parse restart signal timestamp: %s", exc, extra={"path": str(path)})
1763
2031
 
1764
2032
  if path != RESTART_SIGNAL_PATH:
1765
2033
  log.info(
1766
- "从兼容路径读取重启信号",
2034
+ "Read restart signal from compatible path",
1767
2035
  extra={"path": str(path), "primary": str(RESTART_SIGNAL_PATH)},
1768
2036
  )
1769
2037
  return raw, path
@@ -1772,22 +2040,22 @@ def _read_restart_signal() -> Tuple[Optional[dict], Optional[Path]]:
1772
2040
 
1773
2041
 
1774
2042
  async def _notify_restart_success(bot: Bot) -> None:
1775
- """在新 master 启动时读取 signal 并通知触发者(改进版:支持超时检测和详细诊断)"""
1776
- restart_expected = os.environ.pop("MASTER_RESTART_EXPECTED", None)
2043
+ """Read the signal and notify the triggerer when the new master starts (improved version: supports timeout detection and detailed diagnosis)"""
2044
+ restart_expected=os.environ.pop("MASTER_RESTART_EXPECTED", None)
1777
2045
  payload, signal_path = _read_restart_signal()
1778
2046
 
1779
- # 定义重启健康检查阈值(2 分钟)
1780
- RESTART_HEALTHY_THRESHOLD = 120 #
1781
- RESTART_WARNING_THRESHOLD = 60 # 超过 1 分钟发出警告
2047
+ # Define restart health check thresholds (2 minutes)
2048
+ RESTART_HEALTHY_THRESHOLD = 120 # seconds
2049
+ RESTART_WARNING_THRESHOLD = 60 # Warn after more than 1 minute
1782
2050
 
1783
2051
  if not payload:
1784
2052
  if restart_expected:
1785
2053
  targets = _collect_admin_targets()
1786
2054
  log.warning(
1787
- "启动时未检测到重启信号文件,将向管理员发送兜底提醒", extra={"targets": targets}
2055
+ "If the restart signal file is not detected during startup, a cryptic reminder will be sent to the administrator.", extra={"targets": targets}
1788
2056
  )
1789
2057
  if targets:
1790
- # 检查启动日志是否有错误信息
2058
+ # Check the startup log for error messages
1791
2059
  error_log_dir = LOG_ROOT_PATH
1792
2060
  error_log_hint = ""
1793
2061
  try:
@@ -1795,23 +2063,23 @@ async def _notify_restart_success(bot: Bot) -> None:
1795
2063
  if error_logs:
1796
2064
  latest_error_log = error_logs[0]
1797
2065
  if latest_error_log.stat().st_size > 0:
1798
- error_log_hint = f"\n⚠️ 发现错误日志:{latest_error_log}"
2066
+ error_log_hint = f"\n⚠️ Error log found: {latest_error_log}"
1799
2067
  except Exception:
1800
2068
  pass
1801
2069
 
1802
2070
  text_lines = [
1803
- "⚠️ Master 已重新上线,但未找到重启触发者信息。",
2071
+ "⚠️ Master It's back online, but no information about the restart trigger was found. ",
1804
2072
  "",
1805
- "可能原因:",
1806
- "1. 重启信号文件写入失败",
1807
- "2. 信号文件已超时被清理(TTL=30分钟)",
1808
- "3. 文件系统权限问题",
1809
- "4. start.sh 启动失败后被清理",
2073
+ "Possible reasons: ",
2074
+ "1. Restart signal file writing failed",
2075
+ "2. The signal file has timed out and been cleared (TTL=30 minutes)",
2076
+ "3. File system permission issues",
2077
+ "4. start.sh Cleaned up after failed startup",
1810
2078
  "",
1811
- "建议检查:",
1812
- f"- 启动日志: {LOG_ROOT_PATH / 'start.log'}",
1813
- f"- 运行日志: {LOG_ROOT_PATH / 'vibe.log'}",
1814
- f"- 信号文件: {RESTART_SIGNAL_PATH}",
2079
+ "Recommended to check: ",
2080
+ f"- Startup log: {LOG_ROOT_PATH / 'start.log'}",
2081
+ f"- Running log: {LOG_ROOT_PATH / 'vibe.log'}",
2082
+ f"- Signal file: {RESTART_SIGNAL_PATH}",
1815
2083
  ]
1816
2084
  if error_log_hint:
1817
2085
  text_lines.append(error_log_hint)
@@ -1820,18 +2088,18 @@ async def _notify_restart_success(bot: Bot) -> None:
1820
2088
  for chat in targets:
1821
2089
  try:
1822
2090
  await bot.send_message(chat_id=chat, text=text)
1823
- log.info("兜底重启通知已发送", extra={"chat": chat})
2091
+ log.info("The complete restart notification has been sent", extra={"chat": chat})
1824
2092
  except Exception as exc:
1825
- log.error("发送兜底重启通知失败: %s", exc, extra={"chat": chat})
2093
+ log.error("Failed to send full restart notification: %s", exc, extra={"chat": chat})
1826
2094
  else:
1827
- log.info("启动时未检测到重启信号文件,可能是正常启动。")
2095
+ log.info("The restart signal file was not detected during startup. It may be a normal startup. ")
1828
2096
  return
1829
2097
 
1830
2098
  chat_id_raw = payload.get("chat_id")
1831
2099
  try:
1832
2100
  chat_id = int(chat_id_raw)
1833
2101
  except (TypeError, ValueError):
1834
- log.error("重启信号 chat_id 非法: %s", chat_id_raw)
2102
+ log.error("Restart signal chat_id is illegal: %s", chat_id_raw)
1835
2103
  targets = (signal_path, RESTART_SIGNAL_PATH, *LEGACY_RESTART_SIGNAL_PATHS)
1836
2104
  for candidate in targets:
1837
2105
  if candidate is None:
@@ -1845,7 +2113,7 @@ async def _notify_restart_success(bot: Bot) -> None:
1845
2113
  timestamp_fmt: Optional[str] = None
1846
2114
  restart_duration: Optional[int] = None
1847
2115
 
1848
- # 计算重启耗时
2116
+ # Calculate restart time
1849
2117
  if timestamp:
1850
2118
  try:
1851
2119
  ts = datetime.fromisoformat(timestamp)
@@ -1854,36 +2122,36 @@ async def _notify_restart_success(bot: Bot) -> None:
1854
2122
  ts_local = ts.astimezone(LOCAL_TZ)
1855
2123
  timestamp_fmt = ts_local.strftime("%Y-%m-%d %H:%M:%S %Z")
1856
2124
 
1857
- # 计算重启耗时(秒)
2125
+ # Calculate restart time (seconds)
1858
2126
  now = datetime.now(LOCAL_TZ)
1859
2127
  restart_duration = int((now - ts_local).total_seconds())
1860
2128
  except Exception as exc:
1861
- log.warning("解析重启时间失败: %s", exc)
2129
+ log.warning("Failed to parse restart time: %s", exc)
1862
2130
 
1863
2131
  details = []
1864
2132
  if username:
1865
- details.append(f"触发人:@{username}")
2133
+ details.append(f"Trigger: @{username}")
1866
2134
  elif user_id:
1867
- details.append(f"触发人ID{user_id}")
2135
+ details.append(f"Trigger ID: {user_id}")
1868
2136
  if timestamp_fmt:
1869
- details.append(f"请求时间:{timestamp_fmt}")
2137
+ details.append(f"Request time:{timestamp_fmt}")
1870
2138
 
1871
- # 添加重启耗时信息和健康状态
2139
+ # Add restart time-consuming information and health status
1872
2140
  message_lines = []
1873
2141
  if restart_duration is not None:
1874
2142
  if restart_duration <= RESTART_WARNING_THRESHOLD:
1875
- message_lines.append(f"master 已重新上线 ✅(耗时 {restart_duration}秒)")
2143
+ message_lines.append(f"master Back online ✅(It took {restart_duration} seconds)")
1876
2144
  elif restart_duration <= RESTART_HEALTHY_THRESHOLD:
1877
- message_lines.append(f"⚠️ master 已重新上线(耗时 {restart_duration}秒,略慢)")
1878
- details.append("💡 建议:检查依赖安装是否触发了重新下载")
2145
+ message_lines.append(f"⚠️ master Back online (took {restart_duration} seconds, slightly slower)")
2146
+ details.append("💡 Suggestion: Check if dependency installation triggers a re-download")
1879
2147
  else:
1880
- message_lines.append(f"⚠️ master 已重新上线(耗时 {restart_duration}秒,异常缓慢)")
1881
- details.append("⚠️ 重启耗时过长,建议检查:")
1882
- details.append(" - 网络连接是否正常")
1883
- details.append(" - 依赖安装是否卡住")
1884
- details.append(f" - 启动日志: {LOG_ROOT_PATH / 'start.log'}")
2148
+ message_lines.append(f"⚠️ master Back online (took {restart_duration} seconds, unusually slow)")
2149
+ details.append("⚠️ Restarting takes too long, please check: ")
2150
+ details.append(" - Is the network connection normal?")
2151
+ details.append(" - Whether the dependency installation is stuck")
2152
+ details.append(f" - Startup log: {LOG_ROOT_PATH / 'start.log'}")
1885
2153
  else:
1886
- message_lines.append("master 已重新上线 ✅")
2154
+ message_lines.append("master Back online ✅")
1887
2155
 
1888
2156
  if details:
1889
2157
  message_lines.extend(details)
@@ -1893,10 +2161,10 @@ async def _notify_restart_success(bot: Bot) -> None:
1893
2161
  try:
1894
2162
  await bot.send_message(chat_id=chat_id, text=text)
1895
2163
  except Exception as exc:
1896
- log.error("发送重启成功通知失败: %s", exc, extra={"chat": chat_id})
2164
+ log.error("Failed to send restart successful notification: %s", exc, extra={"chat": chat_id})
1897
2165
  else:
1898
- # 重启成功后不再附带项目列表,避免高频重启时产生额外噪音
1899
- log.info("重启成功通知已发送", extra={"chat": chat_id, "duration": restart_duration})
2166
+ # After a successful restart, the project list will no longer be included to avoid extra noise during high-frequency restarts.
2167
+ log.info("Restart successful notification has been sent", extra={"chat": chat_id, "duration": restart_duration})
1900
2168
  finally:
1901
2169
  candidates = (signal_path, RESTART_SIGNAL_PATH, *LEGACY_RESTART_SIGNAL_PATHS)
1902
2170
  for candidate in candidates:
@@ -1906,33 +2174,33 @@ async def _notify_restart_success(bot: Bot) -> None:
1906
2174
 
1907
2175
 
1908
2176
  async def _ensure_manager() -> MasterManager:
1909
- """确保 MANAGER 已初始化,未初始化时抛出异常。"""
2177
+ """Make sure MANAGER is initialized, throw an exception if it is not initialized. """
1910
2178
 
1911
2179
  global MANAGER
1912
2180
  if MANAGER is None:
1913
- raise RuntimeError("Master manager 未初始化")
2181
+ raise RuntimeError("Master manager is not initialized")
1914
2182
  return MANAGER
1915
2183
 
1916
2184
 
1917
2185
  async def _process_restart_request(
1918
- message: Message,
2186
+ message: message,
1919
2187
  *,
1920
2188
  trigger_user: Optional[User] = None,
1921
2189
  manager: Optional[MasterManager] = None,
1922
2190
  ) -> None:
1923
- """响应 /restart 请求,写入重启信号并触发脚本。"""
2191
+ """Respond to /restart requests, write restart signals and trigger scripts. """
1924
2192
 
1925
2193
  if manager is None:
1926
2194
  manager = await _ensure_manager()
1927
2195
  if not manager.is_authorized(message.chat.id):
1928
- await message.answer("未授权。")
2196
+ await message.answer("Not authorized.")
1929
2197
  return
1930
2198
 
1931
2199
  lock = _ensure_restart_lock()
1932
2200
  async with lock:
1933
- global _restart_in_progress
2201
+ global_restart_in_progress
1934
2202
  if _restart_in_progress:
1935
- await message.answer("已有重启请求在执行,请稍候再试。")
2203
+ await message.answer("A restart request is already being executed, please try again later. ")
1936
2204
  return
1937
2205
  _restart_in_progress = True
1938
2206
 
@@ -1940,7 +2208,7 @@ async def _process_restart_request(
1940
2208
  if not start_script.exists():
1941
2209
  async with lock:
1942
2210
  _restart_in_progress = False
1943
- await message.answer("未找到 ./start.sh,无法执行重启。")
2211
+ await message.answer("not found ./start.sh,Unable to perform restart. ")
1944
2212
  return
1945
2213
 
1946
2214
  signal_error: Optional[str] = None
@@ -1948,14 +2216,14 @@ async def _process_restart_request(
1948
2216
  _write_restart_signal(message, override_user=trigger_user)
1949
2217
  except Exception as exc:
1950
2218
  signal_error = str(exc)
1951
- log.error("记录重启信号异常: %s", exc)
2219
+ log.error("Record restart signal exception: %s", exc)
1952
2220
 
1953
2221
  notice = (
1954
- "已收到重启指令,运行期间 master 会短暂离线,重启后所有 worker 需稍后手动启动。"
2222
+ "The restart command has been received. The master will be temporarily offline during operation. After restarting, all workers need to be started manually later. "
1955
2223
  )
1956
2224
  if signal_error:
1957
2225
  notice += (
1958
- "\n⚠️ 重启信号写入失败,可能无法在重启完成后自动通知。原因: "
2226
+ "\n⚠️ The restart signal writing failed and may not be automatically notified after the restart is completed. Reason: "
1959
2227
  f"{signal_error}"
1960
2228
  )
1961
2229
 
@@ -1966,18 +2234,18 @@ async def _process_restart_request(
1966
2234
 
1967
2235
  @router.message(CommandStart())
1968
2236
  async def cmd_start(message: Message) -> None:
1969
- """处理 /start 命令,返回项目概览与状态。"""
2237
+ """Handles the /start command and returns project overview and status. """
1970
2238
 
1971
2239
  _log_update(message)
1972
2240
  manager = await _ensure_manager()
1973
2241
  if not manager.is_authorized(message.chat.id):
1974
- await message.answer("未授权。")
2242
+ await message.answer("Not authorized.")
1975
2243
  return
1976
2244
  manager.refresh_state()
1977
2245
  await message.answer(
1978
- f"Master bot 已启动(v{__version__})。\n"
1979
- f"已登记项目: {len(manager.configs)} 个。\n"
1980
- "使用 /projects 查看状态,/run /stop 控制 worker。",
2246
+ f"Master bot Started (v{__version__}). \n"
2247
+ f"Registered items: {len(manager.configs)} indivual. \n"
2248
+ "Use /projects to view status, /run or /stop to control workers. ",
1981
2249
  reply_markup=_build_master_main_keyboard(),
1982
2250
  )
1983
2251
  await _send_projects_overview_to_chat(
@@ -1989,8 +2257,8 @@ async def cmd_start(message: Message) -> None:
1989
2257
 
1990
2258
 
1991
2259
  async def _perform_restart(message: Message, start_script: Path) -> None:
1992
- """异步执行 ./start.sh,若失败则回滚标记并通知管理员"""
1993
- global _restart_in_progress
2260
+ """Asynchronous execution ./start.sh,If it fails, roll back the mark and notify the administrator """
2261
+ global_restart_in_progress
1994
2262
  lock = _ensure_restart_lock()
1995
2263
  bot = message.bot
1996
2264
  chat_id = message.chat.id
@@ -2001,41 +2269,41 @@ async def _perform_restart(message: Message, start_script: Path) -> None:
2001
2269
  try:
2002
2270
  await bot.send_message(
2003
2271
  chat_id=chat_id,
2004
- text="开始重启,当前 master 将退出并重新拉起,请稍候。",
2272
+ text="Start restarting, the current master will exit and restart, please wait. ",
2005
2273
  )
2006
2274
  except Exception as notice_exc:
2007
2275
  notice_error = notice_exc
2008
- log.warning("发送启动通知失败: %s", notice_exc)
2276
+ log.warning("Failed to send startup notification: %s", notice_exc)
2009
2277
  try:
2010
- # 使用 DEVNULL 避免继承当前 stdout/stderr,防止父进程退出导致 start.sh 写入管道时触发 BrokenPipe。
2011
- proc = subprocess.Popen(
2278
+ # Use DEVNULL to avoid inheriting the current stdout/stderr and prevent the parent process from exiting and causing start.sh BrokenPipe is triggered when writing to the pipe.
2279
+ proc=subprocess.Popen(
2012
2280
  ["/bin/bash", str(start_script)],
2013
2281
  cwd=str(ROOT_DIR),
2014
2282
  env=env,
2015
2283
  stdout=subprocess.DEVNULL,
2016
2284
  stderr=subprocess.DEVNULL,
2017
2285
  )
2018
- log.info("已触发 start.sh 进行重启,pid=%s", proc.pid if proc else "-")
2286
+ log.info("start triggered.sh Restart, pid=%s", proc.pid if proc else "-")
2019
2287
  except Exception as exc:
2020
- log.error("执行 ./start.sh 失败: %s", exc)
2288
+ log.error("implement ./start.sh Failure: %s", exc)
2021
2289
  async with lock:
2022
2290
  _restart_in_progress = False
2023
2291
  try:
2024
- await bot.send_message(chat_id=chat_id, text=f"执行 ./start.sh 失败:{exc}")
2292
+ await bot.send_message(chat_id=chat_id, text=f"implement ./start.sh Failure: {exc}")
2025
2293
  except Exception as send_exc:
2026
- log.error("发送重启失败通知时出错: %s", send_exc)
2294
+ log.error("Error sending restart failure notification: %s", send_exc)
2027
2295
  return
2028
2296
  else:
2029
2297
  if notice_error:
2030
- log.warning("启动通知未送达,已继续执行 start.sh")
2298
+ log.warning("The startup notification was not delivered and execution of start has continued..sh")
2031
2299
  async with lock:
2032
2300
  _restart_in_progress = False
2033
- log.debug("重启执行中,已提前重置状态标记")
2301
+ log.debug("Restart execution, status flag has been reset in advance")
2034
2302
 
2035
2303
 
2036
2304
  @router.message(Command("restart"))
2037
2305
  async def cmd_restart(message: Message) -> None:
2038
- """处理 /restart 命令,触发 master 重启。"""
2306
+ """Process the /restart command to trigger a master restart. """
2039
2307
 
2040
2308
  _log_update(message)
2041
2309
  await _process_restart_request(message)
@@ -2047,16 +2315,17 @@ async def _send_projects_overview_to_chat(
2047
2315
  manager: MasterManager,
2048
2316
  reply_to_message_id: Optional[int] = None,
2049
2317
  ) -> None:
2050
- """向指定聊天发送项目概览及操作按钮。"""
2318
+ """Send project overview and action buttons to the specified chat. """
2051
2319
 
2320
+ await _maybe_notify_update(bot, chat_id)
2052
2321
  manager.refresh_state()
2053
2322
  try:
2054
2323
  text, markup = _projects_overview(manager)
2055
2324
  except Exception as exc:
2056
- log.exception("生成项目概览失败: %s", exc)
2325
+ log.exception("Failed to generate project overview: %s", exc)
2057
2326
  await bot.send_message(
2058
2327
  chat_id=chat_id,
2059
- text="项目列表生成失败,请稍后再试。",
2328
+ text="Project list generation failed, please try again later. ",
2060
2329
  reply_to_message_id=reply_to_message_id,
2061
2330
  )
2062
2331
  return
@@ -2068,28 +2337,28 @@ async def _send_projects_overview_to_chat(
2068
2337
  reply_to_message_id=reply_to_message_id,
2069
2338
  )
2070
2339
  except TelegramBadRequest as exc:
2071
- log.error("发送项目概览失败: %s", exc)
2340
+ log.error("Failed to send project overview: %s", exc)
2072
2341
  await bot.send_message(
2073
2342
  chat_id=chat_id,
2074
2343
  text=text,
2075
2344
  reply_to_message_id=reply_to_message_id,
2076
2345
  )
2077
2346
  except Exception as exc:
2078
- log.exception("发送项目概览触发异常: %s", exc)
2347
+ log.exception("Sending project overview triggers exception: %s", exc)
2079
2348
  await bot.send_message(
2080
2349
  chat_id=chat_id,
2081
2350
  text=text,
2082
2351
  reply_to_message_id=reply_to_message_id,
2083
2352
  )
2084
2353
  else:
2085
- log.info("已发送项目概览,按钮=%s", "" if markup is None else "")
2354
+ log.info("Project overview sent, button=%s", "None" if markup is None else "yes")
2086
2355
 
2087
2356
 
2088
2357
  async def _refresh_project_overview(
2089
2358
  message: Optional[Message],
2090
2359
  manager: MasterManager,
2091
2360
  ) -> None:
2092
- """在原消息上刷新项目概览,无法编辑时发送新消息。"""
2361
+ """Refresh the project overview on the original message and send a new message if editing is not possible. """
2093
2362
 
2094
2363
  if message is None:
2095
2364
  return
@@ -2097,26 +2366,26 @@ async def _refresh_project_overview(
2097
2366
  try:
2098
2367
  text, markup = _projects_overview(manager)
2099
2368
  except Exception as exc:
2100
- log.exception("刷新项目概览失败: %s", exc)
2369
+ log.exception("Failed to refresh project overview: %s", exc)
2101
2370
  return
2102
2371
  try:
2103
2372
  await message.edit_text(text, reply_markup=markup)
2104
2373
  except TelegramBadRequest as exc:
2105
- log.warning("编辑项目概览失败,将发送新消息: %s", exc)
2374
+ log.warning("Failed to edit project overview, new message will be sent: %s", exc)
2106
2375
  try:
2107
2376
  await message.answer(text, reply_markup=markup)
2108
2377
  except Exception as send_exc:
2109
- log.exception("发送项目概览失败: %s", send_exc)
2378
+ log.exception("Failed to send project overview: %s", send_exc)
2110
2379
 
2111
2380
 
2112
2381
  @router.message(Command("projects"))
2113
2382
  async def cmd_projects(message: Message) -> None:
2114
- """处理 /projects 命令,返回最新项目概览。"""
2383
+ """Processes the /projects command, returning an overview of the latest projects. """
2115
2384
 
2116
2385
  _log_update(message)
2117
2386
  manager = await _ensure_manager()
2118
2387
  if not manager.is_authorized(message.chat.id):
2119
- await message.answer("未授权。")
2388
+ await message.answer("Not authorized.")
2120
2389
  return
2121
2390
  await _send_projects_overview_to_chat(
2122
2391
  message.bot,
@@ -2126,14 +2395,35 @@ async def cmd_projects(message: Message) -> None:
2126
2395
  )
2127
2396
 
2128
2397
 
2398
+ @router.message(Command("upgrade"))
2399
+ async def cmd_upgrade(message: Message) -> None:
2400
+ """Handle /upgrade command, trigger pipx upgrade and restart service. """
2401
+
2402
+ _log_update(message)
2403
+ manager = await _ensure_manager()
2404
+ if not manager.is_authorized(message.chat.id):
2405
+ await message.answer("Not authorized.")
2406
+ return
2407
+
2408
+ success, error = _trigger_upgrade_pipeline()
2409
+ if success:
2410
+ notice = (
2411
+ "Triggered `pipx upgrade vibego && vibego stop && vibego start`.\n"
2412
+ "The master will restart briefly during the upgrade process, please use /start to verify the status later. "
2413
+ )
2414
+ await message.answer(notice, parse_mode="Markdown")
2415
+ else:
2416
+ await message.answer(f"Failed to trigger upgrade command: {error}")
2417
+
2418
+
2129
2419
  async def _run_and_reply(message: Message, action: str, coro) -> None:
2130
- """执行异步操作并统一回复成功或失败提示。"""
2420
+ """Perform asynchronous operations and uniformly reply with success or failure prompts. """
2131
2421
 
2132
2422
  try:
2133
2423
  result = await coro
2134
2424
  except Exception as exc:
2135
- log.error("%s 失败: %s", action, exc)
2136
- await message.answer(f"{action} 失败: {exc}")
2425
+ log.error("%s Failure: %s", action, exc)
2426
+ await message.answer(f"{action} Failure: {exc}")
2137
2427
  else:
2138
2428
  reply_text: str
2139
2429
  reply_markup: Optional[InlineKeyboardMarkup] = None
@@ -2142,26 +2432,26 @@ async def _run_and_reply(message: Message, action: str, coro) -> None:
2142
2432
  if len(result) > 1:
2143
2433
  reply_markup = result[1]
2144
2434
  else:
2145
- reply_text = result if isinstance(result, str) else f"{action} 完成"
2435
+ reply_text = result if isinstance(result, str) else f"{action} Complete"
2146
2436
  await message.answer(reply_text, reply_markup=_ensure_numbered_markup(reply_markup))
2147
2437
 
2148
2438
 
2149
2439
  @router.callback_query(F.data.startswith("project:"))
2150
2440
  async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2151
- """处理项目管理相关的回调按钮。"""
2441
+ """Handle callback buttons related to project management. """
2152
2442
 
2153
2443
  manager = await _ensure_manager()
2154
2444
  user_id = callback.from_user.id if callback.from_user else None
2155
2445
  if user_id is None or not manager.is_authorized(user_id):
2156
- await callback.answer("未授权。", show_alert=True)
2446
+ await callback.answer("Not authorized.", show_alert=True)
2157
2447
  return
2158
2448
  data = callback.data or ""
2159
- # 跳过删除确认/取消,让专用处理器接管,避免误判为未知操作。
2449
+ # Skip deletion confirmation/cancellation and let the dedicated processor take over to avoid misjudgment of unknown operations.
2160
2450
  if data.startswith("project:delete_confirm:") or data == "project:delete_cancel":
2161
2451
  raise SkipHandler()
2162
2452
  parts = data.split(":")
2163
2453
  if len(parts) < 3:
2164
- await callback.answer("无效操作", show_alert=True)
2454
+ await callback.answer("Invalid operation", show_alert=True)
2165
2455
  return
2166
2456
  _, action, *rest = parts
2167
2457
  identifier = rest[0] if rest else "*"
@@ -2176,7 +2466,7 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2176
2466
  project_slug = "*"
2177
2467
 
2178
2468
  if action == "refresh":
2179
- # 刷新列表属于全局操作,不依赖具体项目 slug
2469
+ # Refreshing the list is a global operation and does not depend on specific project slugs.
2180
2470
  if callback.message:
2181
2471
  _reload_manager_configs(manager)
2182
2472
  manager.refresh_state()
@@ -2194,23 +2484,23 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2194
2484
  else:
2195
2485
  cfg = manager.require_project_by_slug(project_slug)
2196
2486
  except ValueError:
2197
- await callback.answer("未知项目", show_alert=True)
2487
+ await callback.answer("Unknown project", show_alert=True)
2198
2488
  return
2199
2489
 
2200
- # 关键:避免覆盖 aiogram 传入的 FSMContext,因此运行态单独保存在 project_runtime_state
2490
+ # Key: Avoid overwriting the FSMContext passed in by aiogram, so the running state is saved separately in project_runtime_state
2201
2491
  project_runtime_state = _get_project_runtime_state(manager, cfg.project_slug) if cfg else None
2202
2492
  model_name_map = dict(SWITCHABLE_MODELS)
2203
2493
 
2204
2494
  if cfg:
2205
2495
  log.info(
2206
- "按钮操作请求: user=%s action=%s project=%s",
2496
+ "Button operation request: user=%s action=%s project=%s",
2207
2497
  user_id,
2208
2498
  action,
2209
2499
  cfg.display_name,
2210
2500
  extra={"project": cfg.project_slug},
2211
2501
  )
2212
2502
  else:
2213
- log.info("按钮操作请求: user=%s action=%s 所有项目", user_id, action)
2503
+ log.info("Button action request: user=%s action=%s all items", user_id, action)
2214
2504
 
2215
2505
  if action == "switch_all":
2216
2506
  builder = InlineKeyboardBuilder()
@@ -2223,25 +2513,25 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2223
2513
  )
2224
2514
  builder.row(
2225
2515
  InlineKeyboardButton(
2226
- text="⬅️ 取消",
2516
+ text="⬅️ Cancel",
2227
2517
  callback_data="project:refresh:*",
2228
2518
  )
2229
2519
  )
2230
2520
  await callback.answer()
2231
2521
  await callback.message.answer(
2232
- "请选择全局模型:",
2522
+ "Please select a global model:",
2233
2523
  reply_markup=_ensure_numbered_markup(builder.as_markup()),
2234
2524
  )
2235
2525
  return
2236
2526
 
2237
2527
  if action == "manage":
2238
2528
  if cfg is None or callback.message is None:
2239
- await callback.answer("未知项目", show_alert=True)
2529
+ await callback.answer("Unknown project", show_alert=True)
2240
2530
  return
2241
2531
  builder = InlineKeyboardBuilder()
2242
2532
  builder.row(
2243
2533
  InlineKeyboardButton(
2244
- text="📝 编辑",
2534
+ text="📝 edit",
2245
2535
  callback_data=f"project:edit:{cfg.project_slug}",
2246
2536
  )
2247
2537
  )
@@ -2252,19 +2542,19 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2252
2542
  current_model_label = model_name_map.get(current_model_key, current_model_value or current_model_key or "-")
2253
2543
  builder.row(
2254
2544
  InlineKeyboardButton(
2255
- text=f"🧠 切换模型(当前模型 {current_model_label}",
2545
+ text=f"🧠 Switch model (current model {current_model_label})",
2256
2546
  callback_data=f"project:switch_prompt:{cfg.project_slug}",
2257
2547
  )
2258
2548
  )
2259
2549
  builder.row(
2260
2550
  InlineKeyboardButton(
2261
- text="🗑 删除",
2551
+ text="🗑 delete",
2262
2552
  callback_data=f"project:delete:{cfg.project_slug}",
2263
2553
  )
2264
2554
  )
2265
2555
  builder.row(
2266
2556
  InlineKeyboardButton(
2267
- text="⬅️ 返回项目列表",
2557
+ text="⬅️ Return to project list",
2268
2558
  callback_data="project:refresh:*",
2269
2559
  )
2270
2560
  )
@@ -2272,30 +2562,30 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2272
2562
  _ensure_numbered_markup(markup)
2273
2563
  await callback.answer()
2274
2564
  await callback.message.answer(
2275
- f"项目 {cfg.display_name} 的管理操作:",
2565
+ f"Project {cfg.display_name} management options:",
2276
2566
  reply_markup=markup,
2277
2567
  )
2278
2568
  return
2279
2569
 
2280
2570
  if action == "switch_prompt":
2281
2571
  if cfg is None or callback.message is None:
2282
- await callback.answer("未知项目", show_alert=True)
2572
+ await callback.answer("Unknown project", show_alert=True)
2283
2573
  return
2284
2574
  current_model = (
2285
2575
  project_runtime_state.model if project_runtime_state else cfg.default_model
2286
2576
  ).lower()
2287
2577
  builder = InlineKeyboardBuilder()
2288
2578
  for value, label in SWITCHABLE_MODELS:
2289
- prefix = " " if current_model == value else ""
2290
- builder.row(
2291
- InlineKeyboardButton(
2292
- text=f"{prefix}{label}",
2293
- callback_data=f"project:switch_to:{value}:{cfg.project_slug}",
2294
- )
2579
+ prefix = "[active] " if current_model == value else ""
2580
+ builder.row(
2581
+ InlineKeyboardButton(
2582
+ text=f"{prefix}{label}",
2583
+ callback_data=f"project:switch_to:{value}:{cfg.project_slug}",
2295
2584
  )
2585
+ )
2296
2586
  builder.row(
2297
2587
  InlineKeyboardButton(
2298
- text="⬅️ 返回项目列表",
2588
+ text="⬅️ Return to project list",
2299
2589
  callback_data="project:refresh:*",
2300
2590
  )
2301
2591
  )
@@ -2303,21 +2593,21 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2303
2593
  _ensure_numbered_markup(markup)
2304
2594
  await callback.answer()
2305
2595
  await callback.message.answer(
2306
- f"请选择 {cfg.display_name} 要使用的模型:",
2596
+ f"Select a model for {cfg.display_name}:",
2307
2597
  reply_markup=markup,
2308
2598
  )
2309
2599
  return
2310
2600
 
2311
2601
  if action == "edit":
2312
2602
  if cfg is None:
2313
- await callback.answer("未知项目", show_alert=True)
2603
+ await callback.answer("Unknown project", show_alert=True)
2314
2604
  return
2315
2605
  await _start_project_edit(callback, cfg, manager)
2316
2606
  return
2317
2607
 
2318
2608
  if action == "delete":
2319
2609
  if cfg is None:
2320
- await callback.answer("未知项目", show_alert=True)
2610
+ await callback.answer("Unknown project", show_alert=True)
2321
2611
  return
2322
2612
  await _start_project_delete(callback, cfg, manager, state)
2323
2613
  return
@@ -2327,31 +2617,31 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2327
2617
  return
2328
2618
 
2329
2619
  if action == "restart_master":
2330
- await callback.answer("已收到重启指令")
2620
+ await callback.answer("Restart command received")
2331
2621
 
2332
2622
  try:
2333
2623
  if action == "stop_all":
2334
2624
  await manager.stop_all(update_state=True)
2335
- log.info("按钮操作成功: user=%s 停止全部项目", user_id)
2625
+ log.info("Button operation successful: user=%s Stop all projects", user_id)
2336
2626
  elif action == "start_all":
2337
- # 为所有项目自动记录启动者的 chat_id
2627
+ # Automatically record the initiator's chat_id for all projects
2338
2628
  if callback.message and callback.message.chat:
2339
2629
  for project_cfg in manager.configs:
2340
2630
  current_state = manager.state_store.data.get(project_cfg.project_slug)
2341
2631
  if not current_state or not current_state.chat_id:
2342
2632
  manager.update_chat_id(project_cfg.project_slug, callback.message.chat.id)
2343
2633
  log.info(
2344
- "自动记录 chat_id: project=%s, chat_id=%s",
2634
+ "Automatically record chat_id: project=%s, chat_id=%s",
2345
2635
  project_cfg.project_slug,
2346
2636
  callback.message.chat.id,
2347
2637
  extra={"project": project_cfg.project_slug, "chat_id": callback.message.chat.id},
2348
2638
  )
2349
2639
  await manager.run_all()
2350
- log.info("按钮操作成功: user=%s 启动全部项目", user_id)
2351
- await callback.answer("全部项目已启动,正在刷新列表…")
2640
+ log.info("Button operation successful: user=%s Start all projects", user_id)
2641
+ await callback.answer("All projects have been started and the list is being refreshed...")
2352
2642
  elif action == "restart_master":
2353
2643
  if callback.message is None:
2354
- log.error("重启按钮回调缺少 message 对象", extra={"user": user_id})
2644
+ log.error("Restart button callback is missing message object", extra={"user": user_id})
2355
2645
  return
2356
2646
  _log_update(callback.message, override_user=callback.from_user)
2357
2647
  await _process_restart_request(
@@ -2359,44 +2649,44 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2359
2649
  trigger_user=callback.from_user,
2360
2650
  manager=manager,
2361
2651
  )
2362
- log.info("按钮操作成功: user=%s 重启 master", user_id)
2363
- return # 重启后不刷新项目列表,避免产生额外噪音
2652
+ log.info("Button operation successful: user=%s Restart master", user_id)
2653
+ return # Do not refresh the project list after restarting to avoid additional noise
2364
2654
  elif action == "run":
2365
- # 自动记录启动者的 chat_id
2655
+ # Automatically record the chat_id of the initiator
2366
2656
  if callback.message and callback.message.chat:
2367
2657
  current_state = manager.state_store.data.get(cfg.project_slug)
2368
2658
  if not current_state or not current_state.chat_id:
2369
2659
  manager.update_chat_id(cfg.project_slug, callback.message.chat.id)
2370
2660
  log.info(
2371
- "自动记录 chat_id: project=%s, chat_id=%s",
2661
+ "Automatically record chat_id: project=%s, chat_id=%s",
2372
2662
  cfg.project_slug,
2373
2663
  callback.message.chat.id,
2374
2664
  extra={"project": cfg.project_slug, "chat_id": callback.message.chat.id},
2375
2665
  )
2376
2666
  chosen = await manager.run_worker(cfg)
2377
2667
  log.info(
2378
- "按钮操作成功: user=%s 启动 %s (model=%s)",
2668
+ "Button operation successful: user=%s starts %s (model=%s)",
2379
2669
  user_id,
2380
2670
  cfg.display_name,
2381
2671
  chosen,
2382
2672
  extra={"project": cfg.project_slug, "model": chosen},
2383
2673
  )
2384
- await callback.answer("项目已启动,正在刷新列表…")
2674
+ await callback.answer("The project has been started, refreshing the list...")
2385
2675
  elif action == "stop":
2386
2676
  await manager.stop_worker(cfg)
2387
2677
  log.info(
2388
- "按钮操作成功: user=%s 停止 %s",
2678
+ "Button operation successful: user=%s Stop %s",
2389
2679
  user_id,
2390
2680
  cfg.display_name,
2391
2681
  extra={"project": cfg.project_slug},
2392
2682
  )
2393
- await callback.answer("项目已停止,正在刷新列表…")
2683
+ await callback.answer("The project has been stopped, refreshing the list...")
2394
2684
  elif action == "switch_all_to":
2395
2685
  model_map = dict(SWITCHABLE_MODELS)
2396
2686
  if target_model not in model_map:
2397
- await callback.answer("不支持的模型", show_alert=True)
2687
+ await callback.answer("Unsupported model", show_alert=True)
2398
2688
  return
2399
- await callback.answer("全局切换中,请稍候…")
2689
+ await callback.answer("Global switching, please wait...")
2400
2690
  errors: list[tuple[str, str]] = []
2401
2691
  updated: list[str] = []
2402
2692
  for project_cfg in manager.configs:
@@ -2412,18 +2702,18 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2412
2702
  if errors:
2413
2703
  failure_lines = "\n".join(f"- {name}: {err}" for name, err in errors)
2414
2704
  message_text = (
2415
- f"已尝试将全部项目模型切换为 {label},但部分项目执行失败:\n{failure_lines}"
2705
+ f"An attempt was made to switch all project models to {label}, but execution failed for some projects:\n{failure_lines}"
2416
2706
  )
2417
2707
  log.warning(
2418
- "全局模型切换部分失败: user=%s model=%s failures=%s",
2708
+ "Global model switching partial failure: user=%s model=%s failures=%s",
2419
2709
  user_id,
2420
2710
  target_model,
2421
2711
  [name for name, _ in errors],
2422
2712
  )
2423
2713
  else:
2424
- message_text = f"所有项目模型已切换为 {label},并保持停止状态。"
2714
+ message_text = f"All project models have been switched to {label} and remain stopped. "
2425
2715
  log.info(
2426
- "按钮操作成功: user=%s 全部切换模型至 %s",
2716
+ "Button operation successful: user=%s Switch all models to %s",
2427
2717
  user_id,
2428
2718
  target_model,
2429
2719
  )
@@ -2431,17 +2721,17 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2431
2721
  elif action == "switch_to":
2432
2722
  model_map = dict(SWITCHABLE_MODELS)
2433
2723
  if target_model not in model_map:
2434
- await callback.answer("不支持的模型", show_alert=True)
2724
+ await callback.answer("Unsupported model", show_alert=True)
2435
2725
  return
2436
2726
  state = manager.state_store.data.get(cfg.project_slug)
2437
2727
  previous_model = state.model if state else cfg.default_model
2438
2728
  was_running = bool(state and state.status == "running")
2439
- # 自动记录 chat_id(如果还没有的话)
2729
+ # Automatically record chat_id if not already available
2440
2730
  if callback.message and callback.message.chat:
2441
2731
  if not state or not state.chat_id:
2442
2732
  manager.update_chat_id(cfg.project_slug, callback.message.chat.id)
2443
2733
  log.info(
2444
- "模型切换时自动记录 chat_id: project=%s, chat_id=%s",
2734
+ "Automatically record chat_id when switching models: project=%s, chat_id=%s",
2445
2735
  cfg.project_slug,
2446
2736
  callback.message.chat.id,
2447
2737
  extra={"project": cfg.project_slug, "chat_id": callback.message.chat.id},
@@ -2461,44 +2751,44 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2461
2751
  await manager.run_worker(cfg, model=previous_model)
2462
2752
  except Exception as restore_exc:
2463
2753
  log.error(
2464
- "模型切换失败且恢复失败: %s",
2754
+ "Model switch failed and recovery failed: %s",
2465
2755
  restore_exc,
2466
2756
  extra={"project": cfg.project_slug, "model": previous_model},
2467
2757
  )
2468
2758
  raise
2469
2759
  else:
2470
2760
  if was_running:
2471
- await callback.answer(f"已切换至 {model_map.get(chosen, chosen)}")
2761
+ await callback.answer(f"Switched to {model_map.get(chosen, chosen)}")
2472
2762
  log.info(
2473
- "按钮操作成功: user=%s %s 切换至 %s",
2763
+ "Button operation successful: user=%s switches %s to %s",
2474
2764
  user_id,
2475
2765
  cfg.display_name,
2476
2766
  chosen,
2477
2767
  extra={"project": cfg.project_slug, "model": chosen},
2478
2768
  )
2479
2769
  else:
2480
- await callback.answer(f"默认模型已更新为 {model_map.get(chosen, chosen)}")
2770
+ await callback.answer(f"The default model has been updated to {model_map.get(chosen, chosen)}")
2481
2771
  log.info(
2482
- "按钮操作成功: user=%s 更新 %s 默认模型为 %s",
2772
+ "Button operation successful: user=%s updates %s and the default model is %s",
2483
2773
  user_id,
2484
2774
  cfg.display_name,
2485
2775
  chosen,
2486
2776
  extra={"project": cfg.project_slug, "model": chosen},
2487
2777
  )
2488
2778
  else:
2489
- await callback.answer("未知操作", show_alert=True)
2779
+ await callback.answer("Unknown operation", show_alert=True)
2490
2780
  return
2491
2781
  except Exception as exc:
2492
2782
  log.error(
2493
- "按钮操作失败: action=%s project=%s error=%s",
2783
+ "Button operation failed: action=%s project=%s error=%s",
2494
2784
  action,
2495
2785
  (cfg.display_name if cfg else "*"),
2496
2786
  exc,
2497
2787
  extra={"project": cfg.project_slug if cfg else "*"},
2498
2788
  )
2499
2789
  if callback.message:
2500
- await callback.message.answer(f"操作失败: {exc}")
2501
- await callback.answer("操作失败", show_alert=True)
2790
+ await callback.message.answer(f"Operation failed: {exc}")
2791
+ await callback.answer("Operation failed", show_alert=True)
2502
2792
  return
2503
2793
 
2504
2794
  await _refresh_project_overview(callback.message, manager)
@@ -2506,16 +2796,16 @@ async def on_project_action(callback: CallbackQuery, state: FSMContext) -> None:
2506
2796
 
2507
2797
  @router.message(Command("run"))
2508
2798
  async def cmd_run(message: Message) -> None:
2509
- """处理 /run 命令,启动指定项目并可选切换模型。"""
2799
+ """Processes the /run command, starting the specified project and optionally switching models. """
2510
2800
 
2511
2801
  _log_update(message)
2512
2802
  manager = await _ensure_manager()
2513
2803
  if not manager.is_authorized(message.chat.id):
2514
- await message.answer("未授权。")
2804
+ await message.answer("Not authorized.")
2515
2805
  return
2516
- parts = message.text.split()
2806
+ parts=message.text.split()
2517
2807
  if len(parts) < 2:
2518
- await message.answer("用法: /run <project> [model]")
2808
+ await message.answer("Usage: /run <project> [model]")
2519
2809
  return
2520
2810
  project_raw = parts[1]
2521
2811
  model = parts[2] if len(parts) >= 3 else None
@@ -2526,26 +2816,26 @@ async def cmd_run(message: Message) -> None:
2526
2816
  return
2527
2817
 
2528
2818
  async def runner():
2529
- """调用 manager.run_worker 启动项目并返回提示文本。"""
2819
+ """call manager.run_worker Start the project and return the prompt text. """
2530
2820
 
2531
2821
  chosen = await manager.run_worker(cfg, model=model)
2532
- return f"已启动 {cfg.display_name} (model={chosen})"
2822
+ return f"Started {cfg.display_name} (model={chosen})"
2533
2823
 
2534
- await _run_and_reply(message, "启动", runner())
2824
+ await _run_and_reply(message, "start up", runner())
2535
2825
 
2536
2826
 
2537
2827
  @router.message(Command("stop"))
2538
2828
  async def cmd_stop(message: Message) -> None:
2539
- """处理 /stop 命令,停止指定项目。"""
2829
+ """Process the /stop command to stop the specified project. """
2540
2830
 
2541
2831
  _log_update(message)
2542
2832
  manager = await _ensure_manager()
2543
2833
  if not manager.is_authorized(message.chat.id):
2544
- await message.answer("未授权。")
2834
+ await message.answer("Not authorized.")
2545
2835
  return
2546
- parts = message.text.split()
2836
+ parts=message.text.split()
2547
2837
  if len(parts) < 2:
2548
- await message.answer("用法: /stop <project>")
2838
+ await message.answer("Usage: /stop <project>")
2549
2839
  return
2550
2840
  project_raw = parts[1]
2551
2841
  try:
@@ -2555,26 +2845,26 @@ async def cmd_stop(message: Message) -> None:
2555
2845
  return
2556
2846
 
2557
2847
  async def stopper():
2558
- """停止指定项目并更新状态。"""
2848
+ """Stops the specified project and updates the status. """
2559
2849
 
2560
2850
  await manager.stop_worker(cfg, update_state=True)
2561
- return f"已停止 {cfg.display_name}"
2851
+ return f"Stopped {cfg.display_name}"
2562
2852
 
2563
- await _run_and_reply(message, "停止", stopper())
2853
+ await _run_and_reply(message, "stop", stopper())
2564
2854
 
2565
2855
 
2566
2856
  @router.message(Command("switch"))
2567
2857
  async def cmd_switch(message: Message) -> None:
2568
- """处理 /switch 命令,停机后以新模型重启项目。"""
2858
+ """Handle the /switch command and restart the project with the new model after shutdown. """
2569
2859
 
2570
2860
  _log_update(message)
2571
2861
  manager = await _ensure_manager()
2572
2862
  if not manager.is_authorized(message.chat.id):
2573
- await message.answer("未授权。")
2863
+ await message.answer("Not authorized.")
2574
2864
  return
2575
- parts = message.text.split()
2865
+ parts=message.text.split()
2576
2866
  if len(parts) < 3:
2577
- await message.answer("用法: /switch <project> <model>")
2867
+ await message.answer("Usage: /switch <project> <model>")
2578
2868
  return
2579
2869
  project_raw, model = parts[1], parts[2]
2580
2870
  try:
@@ -2584,31 +2874,31 @@ async def cmd_switch(message: Message) -> None:
2584
2874
  return
2585
2875
 
2586
2876
  async def switcher():
2587
- """重新启动项目并切换到新的模型。"""
2877
+ """Restart the project and switch to the new model. """
2588
2878
 
2589
2879
  await manager.stop_worker(cfg, update_state=True)
2590
2880
  chosen = await manager.run_worker(cfg, model=model)
2591
- return f"已切换 {cfg.display_name} {chosen}"
2881
+ return f"Switched {cfg.display_name} to {chosen}"
2592
2882
 
2593
- await _run_and_reply(message, "切换", switcher())
2883
+ await _run_and_reply(message, "switch", switcher())
2594
2884
 
2595
2885
 
2596
2886
  @router.message(Command("authorize"))
2597
2887
  async def cmd_authorize(message: Message) -> None:
2598
- """处理 /authorize 命令,为项目登记 chat_id"""
2888
+ """Process the /authorize command to register the chat_id for the project. """
2599
2889
 
2600
2890
  _log_update(message)
2601
2891
  manager = await _ensure_manager()
2602
2892
  if not manager.is_authorized(message.chat.id):
2603
- await message.answer("未授权。")
2893
+ await message.answer("Not authorized.")
2604
2894
  return
2605
- parts = message.text.split()
2895
+ parts=message.text.split()
2606
2896
  if len(parts) < 3:
2607
- await message.answer("用法: /authorize <project> <chat_id>")
2897
+ await message.answer("Usage: /authorize <project> <chat_id>")
2608
2898
  return
2609
2899
  project_raw, chat_raw = parts[1], parts[2]
2610
2900
  if not chat_raw.isdigit():
2611
- await message.answer("chat_id 需要是数字")
2901
+ await message.answer("chat_id Needs to be a number")
2612
2902
  return
2613
2903
  chat_id = int(chat_raw)
2614
2904
  try:
@@ -2618,13 +2908,13 @@ async def cmd_authorize(message: Message) -> None:
2618
2908
  return
2619
2909
  manager.update_chat_id(cfg.project_slug, chat_id)
2620
2910
  await message.answer(
2621
- f"已记录 {cfg.display_name} chat_id={chat_id}"
2911
+ f"Logged {cfg.display_name} of chat_id={chat_id}"
2622
2912
  )
2623
2913
 
2624
2914
 
2625
2915
  @router.callback_query(F.data.startswith("project:wizard:skip:"))
2626
2916
  async def on_project_wizard_skip(callback: CallbackQuery) -> None:
2627
- """处理向导中的“跳过此项”按钮。"""
2917
+ """Handle the "Skip this" button in the wizard. """
2628
2918
 
2629
2919
  if callback.message is None or callback.message.chat is None:
2630
2920
  return
@@ -2632,48 +2922,48 @@ async def on_project_wizard_skip(callback: CallbackQuery) -> None:
2632
2922
  async with PROJECT_WIZARD_LOCK:
2633
2923
  session = PROJECT_WIZARD_SESSIONS.get(chat_id)
2634
2924
  if session is None:
2635
- await callback.answer("当前没有进行中的项目流程。", show_alert=True)
2925
+ await callback.answer("There are currently no ongoing project processes. ", show_alert=True)
2636
2926
  return
2637
2927
  if session.step_index >= len(session.fields):
2638
- await callback.answer("当前流程已结束。", show_alert=True)
2928
+ await callback.answer("The current process has ended. ", show_alert=True)
2639
2929
  return
2640
2930
  _, _, field = callback.data.partition("project:wizard:skip:")
2641
2931
  current_field = session.fields[session.step_index]
2642
2932
  if field != current_field:
2643
- await callback.answer("当前步骤已变更,请按最新提示操作。", show_alert=True)
2933
+ await callback.answer("The current steps have been changed, please follow the latest prompts. ", show_alert=True)
2644
2934
  return
2645
2935
  manager = await _ensure_manager()
2646
- await callback.answer("已跳过")
2936
+ await callback.answer("skipped")
2647
2937
  await _advance_wizard_session(
2648
2938
  session,
2649
2939
  manager,
2650
2940
  callback.message,
2651
2941
  "",
2652
- prefix="已跳过 ✅",
2942
+ prefix="skipped ✅",
2653
2943
  )
2654
2944
 
2655
2945
 
2656
2946
  @router.message(F.text.func(_is_projects_menu_trigger))
2657
2947
  async def on_master_projects_button(message: Message) -> None:
2658
- """处理常驻键盘触发的项目概览请求。"""
2948
+ """Handle project overview requests triggered by resident keyboard. """
2659
2949
  _log_update(message)
2660
2950
  manager = await _ensure_manager()
2661
2951
  if not manager.is_authorized(message.chat.id):
2662
- await message.answer("未授权。")
2952
+ await message.answer("Not authorized.")
2663
2953
  return
2664
2954
  requested_text = message.text or ""
2665
2955
  reply_to_message_id: Optional[int] = message.message_id
2666
2956
  if not _text_equals_master_button(requested_text):
2667
2957
  log.info(
2668
- "收到旧版项目列表按钮,准备刷新聊天键盘",
2958
+ "Received an outdated project list button; refreshing the chat keyboard.",
2669
2959
  extra={"text": requested_text, "chat_id": message.chat.id},
2670
2960
  )
2671
2961
  await message.answer(
2672
- "主菜单按钮已更新为“📂 项目列表”,当前会话已同步最新文案。",
2962
+ 'The main menu button is now "📂 Project List"; the session text has been synchronised.',
2673
2963
  reply_markup=_build_master_main_keyboard(),
2674
2964
  reply_to_message_id=reply_to_message_id,
2675
2965
  )
2676
- # 已推送最新键盘,后续回复无需继续引用原消息,避免重复引用提示
2966
+ # The latest keyboard has been pushed. There is no need to continue to quote the original message in subsequent replies to avoid repeated citation prompts.
2677
2967
  reply_to_message_id = None
2678
2968
  await _send_projects_overview_to_chat(
2679
2969
  message.bot,
@@ -2685,14 +2975,14 @@ async def on_master_projects_button(message: Message) -> None:
2685
2975
 
2686
2976
  @router.message(F.text.in_(MASTER_MANAGE_BUTTON_ALLOWED_TEXTS))
2687
2977
  async def on_master_manage_button(message: Message) -> None:
2688
- """处理常驻键盘的项目管理入口。"""
2978
+ """Handle the project management entry for the resident keyboard. """
2689
2979
  _log_update(message)
2690
2980
  manager = await _ensure_manager()
2691
2981
  if not manager.is_authorized(message.chat.id):
2692
- await message.answer("未授权。")
2982
+ await message.answer("Not authorized.")
2693
2983
  return
2694
2984
  builder = InlineKeyboardBuilder()
2695
- builder.row(InlineKeyboardButton(text="➕ 新增项目", callback_data="project:create:*"))
2985
+ builder.row(InlineKeyboardButton(text="➕ New project", callback_data="project:create:*"))
2696
2986
  model_name_map = dict(SWITCHABLE_MODELS)
2697
2987
  for cfg in manager.configs:
2698
2988
  state = manager.state_store.data.get(cfg.project_slug)
@@ -2701,42 +2991,42 @@ async def on_master_manage_button(message: Message) -> None:
2701
2991
  current_model_label = model_name_map.get(current_model_key, current_model_value or current_model_key or "-")
2702
2992
  builder.row(
2703
2993
  InlineKeyboardButton(
2704
- text=f"⚙️ 管理 {cfg.display_name}",
2994
+ text=f"⚙️ Manage {cfg.display_name}",
2705
2995
  callback_data=f"project:manage:{cfg.project_slug}",
2706
2996
  ),
2707
2997
  InlineKeyboardButton(
2708
- text=f"🧠 切换模型(当前模型 {current_model_label}",
2998
+ text=f"🧠 Switch model (current model {current_model_label})",
2709
2999
  callback_data=f"project:switch_prompt:{cfg.project_slug}",
2710
3000
  ),
2711
3001
  )
2712
3002
  builder.row(
2713
3003
  InlineKeyboardButton(
2714
- text="🔁 全部切换模型",
3004
+ text="🔁 Switch all models",
2715
3005
  callback_data="project:switch_all:*",
2716
3006
  )
2717
3007
  )
2718
- builder.row(InlineKeyboardButton(text="📂 返回列表", callback_data="project:refresh:*"))
3008
+ builder.row(InlineKeyboardButton(text="📂 Return to list", callback_data="project:refresh:*"))
2719
3009
  markup = builder.as_markup()
2720
3010
  _ensure_numbered_markup(markup)
2721
3011
  await message.answer(
2722
- "请选择要管理的项目,或点击“➕ 新增项目”创建新的 worker。",
3012
+ 'Select a project to manage, or tap " New project" to create a new worker.',
2723
3013
  reply_markup=markup,
2724
3014
  )
2725
3015
 
2726
3016
 
2727
3017
  @router.message()
2728
3018
  async def cmd_fallback(message: Message) -> None:
2729
- """兜底处理器:尝试继续向导,否则提示可用命令。"""
3019
+ """Fallback handler: resume the wizard when possible; otherwise prompt for the available commands."""
2730
3020
 
2731
3021
  _log_update(message)
2732
3022
  manager = await _ensure_manager()
2733
3023
  if not manager.is_authorized(message.chat.id):
2734
- await message.answer("未授权。")
3024
+ await message.answer("Not authorized.")
2735
3025
  return
2736
3026
  handled = await _handle_wizard_message(message, manager)
2737
3027
  if handled:
2738
3028
  return
2739
- await message.answer("未识别的命令,请使用 /projects /run /stop /switch /authorize")
3029
+ await message.answer("Unrecognized command, please use /projects /run /stop /switch /authorize. ")
2740
3030
 
2741
3031
 
2742
3032
 
@@ -2747,15 +3037,15 @@ def _delete_project_with_fallback(
2747
3037
  original_slug: str,
2748
3038
  bot_name: str,
2749
3039
  ) -> Tuple[Optional[Exception], List[Tuple[str, Exception]]]:
2750
- """尝试以多种标识删除项目,提升大小写与别名兼容性。"""
3040
+ """Try deleting items with multiple identifiers to improve case and alias compatibility. """
2751
3041
 
2752
3042
  attempts: List[Tuple[str, Exception]] = []
2753
3043
 
2754
3044
  def _attempt(candidate: str) -> Optional[Exception]:
2755
- """实际执行删除,失败返回异常供后续兜底。"""
3045
+ """The deletion is actually executed, and an exception is returned on failure for subsequent clarification. """
2756
3046
  slug = (candidate or "").strip()
2757
3047
  if not slug:
2758
- return ValueError("slug 为空")
3048
+ return ValueError("slug is empty")
2759
3049
  try:
2760
3050
  repository.delete_project(slug)
2761
3051
  except ValueError as delete_exc:
@@ -2792,29 +3082,29 @@ def _delete_project_with_fallback(
2792
3082
 
2793
3083
  @router.callback_query(F.data.startswith("project:delete_confirm:"))
2794
3084
  async def on_project_delete_confirm(callback: CallbackQuery, state: FSMContext) -> None:
2795
- """处理删除确认按钮的回调逻辑。"""
3085
+ """Handle the callback logic for deleting the confirmation button. """
2796
3086
  manager = await _ensure_manager()
2797
3087
  user_id = callback.from_user.id if callback.from_user else None
2798
3088
  if user_id is None or not manager.is_authorized(user_id):
2799
- await callback.answer("未授权。", show_alert=True)
3089
+ await callback.answer("Not authorized.", show_alert=True)
2800
3090
  return
2801
3091
  if callback.message is None:
2802
- await callback.answer("无效操作", show_alert=True)
3092
+ await callback.answer("Invalid operation", show_alert=True)
2803
3093
  return
2804
3094
  parts = callback.data.split(":", 2)
2805
3095
  if len(parts) != 3:
2806
- await callback.answer("无效操作", show_alert=True)
3096
+ await callback.answer("Invalid operation", show_alert=True)
2807
3097
  return
2808
3098
  target_slug = parts[2]
2809
3099
  log.info(
2810
- "删除确认回调: user=%s slug=%s",
3100
+ "Delete confirmation callback: user=%s slug=%s",
2811
3101
  user_id,
2812
3102
  target_slug,
2813
3103
  extra={"project": target_slug},
2814
3104
  )
2815
3105
  current_state = await state.get_state()
2816
3106
  if current_state != ProjectDeleteStates.confirming.state:
2817
- await callback.answer("确认流程已过期,请重新发起删除。", show_alert=True)
3107
+ await callback.answer("The confirmation process has expired, please initiate the deletion again. ", show_alert=True)
2818
3108
  return
2819
3109
  data = await state.get_data()
2820
3110
  stored_slug = str(data.get("project_slug", "")).strip()
@@ -2824,11 +3114,11 @@ async def on_project_delete_confirm(callback: CallbackQuery, state: FSMContext)
2824
3114
  await callback.message.edit_reply_markup(reply_markup=None)
2825
3115
  except TelegramBadRequest:
2826
3116
  pass
2827
- await callback.answer("确认信息已失效,请重新发起删除。", show_alert=True)
3117
+ await callback.answer("The confirmation information has expired, please initiate deletion again. ", show_alert=True)
2828
3118
  return
2829
3119
  initiator_id = data.get("initiator_id")
2830
3120
  if initiator_id and initiator_id != user_id:
2831
- await callback.answer("仅流程发起者可以确认删除。", show_alert=True)
3121
+ await callback.answer("Only the process initiator can confirm the deletion. ", show_alert=True)
2832
3122
  return
2833
3123
  expires_at = float(data.get("expires_at") or 0)
2834
3124
  if expires_at and time.time() > expires_at:
@@ -2837,7 +3127,7 @@ async def on_project_delete_confirm(callback: CallbackQuery, state: FSMContext)
2837
3127
  await callback.message.edit_reply_markup(reply_markup=None)
2838
3128
  except TelegramBadRequest:
2839
3129
  pass
2840
- await callback.answer("确认已超时,请重新发起删除。", show_alert=True)
3130
+ await callback.answer("The confirmation has timed out, please initiate deletion again. ", show_alert=True)
2841
3131
  return
2842
3132
  repository = _ensure_repository()
2843
3133
  original_slug = str(data.get("original_slug") or "").strip()
@@ -2850,15 +3140,15 @@ async def on_project_delete_confirm(callback: CallbackQuery, state: FSMContext)
2850
3140
  )
2851
3141
  if error is not None:
2852
3142
  log.error(
2853
- "删除项目失败: %s",
3143
+ "Failed to delete item: %s",
2854
3144
  error,
2855
3145
  extra={
2856
3146
  "slug": stored_slug,
2857
3147
  "attempts": [slug for slug, _ in attempts],
2858
3148
  },
2859
3149
  )
2860
- await callback.answer("删除失败,请稍后重试。", show_alert=True)
2861
- await callback.message.answer(f"删除失败:{error}")
3150
+ await callback.answer("Deletion failed, please try again later. ", show_alert=True)
3151
+ await callback.message.answer(f"Delete failed: {error}")
2862
3152
  return
2863
3153
  await state.clear()
2864
3154
  try:
@@ -2867,35 +3157,35 @@ async def on_project_delete_confirm(callback: CallbackQuery, state: FSMContext)
2867
3157
  pass
2868
3158
  _reload_manager_configs(manager)
2869
3159
  display_name = data.get("display_name") or stored_slug
2870
- await callback.answer("项目已删除")
2871
- await callback.message.answer(f"项目 {display_name} 已删除 ✅")
3160
+ await callback.answer("Project deleted.")
3161
+ await callback.message.answer(f"Project {display_name} deleted ✅")
2872
3162
  await _send_projects_overview_to_chat(callback.message.bot, callback.message.chat.id, manager)
2873
3163
 
2874
3164
 
2875
3165
  @router.callback_query(F.data == "project:delete_cancel")
2876
3166
  async def on_project_delete_cancel(callback: CallbackQuery, state: FSMContext) -> None:
2877
- """处理删除流程的取消按钮。"""
3167
+ """Cancel button that handles the deletion process. """
2878
3168
  manager = await _ensure_manager()
2879
3169
  user_id = callback.from_user.id if callback.from_user else None
2880
3170
  if user_id is None or not manager.is_authorized(user_id):
2881
- await callback.answer("未授权。", show_alert=True)
3171
+ await callback.answer("Not authorized.", show_alert=True)
2882
3172
  return
2883
3173
  if callback.message is None:
2884
- await callback.answer("无效操作", show_alert=True)
3174
+ await callback.answer("Invalid operation", show_alert=True)
2885
3175
  return
2886
3176
  current_state = await state.get_state()
2887
3177
  if current_state != ProjectDeleteStates.confirming.state:
2888
- await callback.answer("当前没有待确认的删除流程。", show_alert=True)
3178
+ await callback.answer("There are currently no pending deletion processes. ", show_alert=True)
2889
3179
  return
2890
3180
  data = await state.get_data()
2891
3181
  log.info(
2892
- "删除取消回调: user=%s slug=%s",
3182
+ "Delete cancellation callback: user=%s slug=%s",
2893
3183
  user_id,
2894
3184
  data.get("project_slug"),
2895
3185
  )
2896
3186
  initiator_id = data.get("initiator_id")
2897
3187
  if initiator_id and initiator_id != user_id:
2898
- await callback.answer("仅流程发起者可以取消删除。", show_alert=True)
3188
+ await callback.answer("Only the process initiator can cancel the deletion. ", show_alert=True)
2899
3189
  return
2900
3190
  expires_at = float(data.get("expires_at") or 0)
2901
3191
  if expires_at and time.time() > expires_at:
@@ -2904,7 +3194,7 @@ async def on_project_delete_cancel(callback: CallbackQuery, state: FSMContext) -
2904
3194
  await callback.message.edit_reply_markup(reply_markup=None)
2905
3195
  except TelegramBadRequest:
2906
3196
  pass
2907
- await callback.answer("确认已超时,请重新发起删除。", show_alert=True)
3197
+ await callback.answer("The confirmation has timed out, please initiate deletion again. ", show_alert=True)
2908
3198
  return
2909
3199
  await state.clear()
2910
3200
  try:
@@ -2912,22 +3202,22 @@ async def on_project_delete_cancel(callback: CallbackQuery, state: FSMContext) -
2912
3202
  except TelegramBadRequest:
2913
3203
  pass
2914
3204
  display_name = data.get("display_name") or data.get("project_slug") or ""
2915
- await callback.answer("删除流程已取消")
2916
- await callback.message.answer(f"已取消删除项目 {display_name}")
3205
+ await callback.answer("Deletion cancelled.")
3206
+ await callback.message.answer(f"Deletion cancelled for project {display_name}.")
2917
3207
 
2918
3208
 
2919
3209
  @router.message(ProjectDeleteStates.confirming)
2920
3210
  async def on_project_delete_text(message: Message, state: FSMContext) -> None:
2921
- """兼容旧版交互,允许通过文本指令确认或取消删除。"""
3211
+ """Compatible with older interactions, allowing text commands to confirm or cancel deletion. """
2922
3212
  manager = await _ensure_manager()
2923
- user = message.from_user
3213
+ user=message.from_user
2924
3214
  if user is None or not manager.is_authorized(user.id):
2925
- await message.answer("未授权。")
3215
+ await message.answer("Not authorized.")
2926
3216
  return
2927
3217
  data = await state.get_data()
2928
3218
  initiator_id = data.get("initiator_id")
2929
3219
  if initiator_id and initiator_id != user.id:
2930
- await message.answer("仅流程发起者可以继续此删除流程。")
3220
+ await message.answer("Only the process initiator can proceed with this deletion process. ")
2931
3221
  return
2932
3222
  expires_at = float(data.get("expires_at") or 0)
2933
3223
  if expires_at and time.time() > expires_at:
@@ -2938,18 +3228,18 @@ async def on_project_delete_text(message: Message, state: FSMContext) -> None:
2938
3228
  await prompt.edit_reply_markup(reply_markup=None)
2939
3229
  except TelegramBadRequest:
2940
3230
  pass
2941
- await message.answer("确认已超时,请重新发起删除。")
3231
+ await message.answer("The confirmation has timed out, please initiate deletion again. ")
2942
3232
  return
2943
3233
 
2944
3234
  raw_text = (message.text or "").strip()
2945
3235
  if not raw_text:
2946
- await message.answer("请使用按钮或输入“确认删除”/“取消”完成操作。")
3236
+ await message.answer('Use the buttons or type "Confirm deletion" / "Cancel" to finish the operation.')
2947
3237
  return
2948
3238
  normalized = raw_text.casefold().strip()
2949
- normalized = normalized.rstrip("。.!??")
3239
+ normalized = normalized.rstrip("..!??")
2950
3240
  normalized_compact = normalized.replace(" ", "")
2951
- confirm_tokens = {"确认删除", "确认", "confirm", "y", "yes"}
2952
- cancel_tokens = {"取消", "cancel", "n", "no"}
3241
+ confirm_tokens = {"confirm deletion", "confirm", "y", "yes"}
3242
+ cancel_tokens = {"cancel", "n", "no"}
2953
3243
 
2954
3244
  if normalized in cancel_tokens or normalized_compact in cancel_tokens:
2955
3245
  await state.clear()
@@ -2960,21 +3250,21 @@ async def on_project_delete_text(message: Message, state: FSMContext) -> None:
2960
3250
  except TelegramBadRequest:
2961
3251
  pass
2962
3252
  display_name = data.get("display_name") or data.get("project_slug") or ""
2963
- await message.answer(f"已取消删除项目 {display_name}")
3253
+ await message.answer(f"Deletion cancelled for project {display_name}.")
2964
3254
  return
2965
3255
 
2966
3256
  if not (
2967
3257
  normalized in confirm_tokens
2968
3258
  or normalized_compact in confirm_tokens
2969
- or normalized.startswith("确认删除")
3259
+ or normalized.startswith("Confirm deletion")
2970
3260
  ):
2971
- await message.answer("请输入“确认删除”或通过按钮完成操作。")
3261
+ await message.answer('Type "Confirm deletion" or use the buttons to finish the operation.')
2972
3262
  return
2973
3263
 
2974
3264
  stored_slug = str(data.get("project_slug", "")).strip()
2975
3265
  if not stored_slug:
2976
3266
  await state.clear()
2977
- await message.answer("删除流程状态异常,请重新发起删除。")
3267
+ await message.answer("The deletion process status is abnormal, please initiate deletion again. ")
2978
3268
  return
2979
3269
  original_slug = str(data.get("original_slug") or "").strip()
2980
3270
  bot_name = str(data.get("bot_name") or "").strip()
@@ -2987,14 +3277,14 @@ async def on_project_delete_text(message: Message, state: FSMContext) -> None:
2987
3277
  )
2988
3278
  if error is not None:
2989
3279
  log.error(
2990
- "删除项目失败(文本确认): %s",
3280
+ "Failed to delete item (text confirmation): %s",
2991
3281
  error,
2992
3282
  extra={
2993
3283
  "slug": stored_slug,
2994
3284
  "attempts": [slug for slug, _ in attempts],
2995
3285
  },
2996
3286
  )
2997
- await message.answer(f"删除失败:{error}")
3287
+ await message.answer(f"Delete failed: {error}")
2998
3288
  return
2999
3289
 
3000
3290
  await state.clear()
@@ -3006,34 +3296,34 @@ async def on_project_delete_text(message: Message, state: FSMContext) -> None:
3006
3296
  pass
3007
3297
  _reload_manager_configs(manager)
3008
3298
  display_name = data.get("display_name") or stored_slug
3009
- await message.answer(f"项目 {display_name} 已删除 ✅")
3299
+ await message.answer(f"Item {display_name} deleted ✅")
3010
3300
  await _send_projects_overview_to_chat(message.bot, message.chat.id, manager)
3011
3301
 
3012
3302
 
3013
3303
 
3014
3304
  async def bootstrap_manager() -> MasterManager:
3015
- """初始化项目仓库、状态存储与 manager,启动前清理旧 worker。"""
3305
+ """Initialize the project warehouse, state storage and manager, and clean up old workers before starting. """
3016
3306
 
3017
3307
  load_env()
3018
- tmux_prefix = os.environ.get("TMUX_SESSION_PREFIX", "vibe")
3308
+ tmux_prefix=os.environ.get("TMUX_SESSION_PREFIX", "vibe")
3019
3309
  _kill_existing_tmux(tmux_prefix)
3020
3310
  try:
3021
3311
  repository = ProjectRepository(CONFIG_DB_PATH, CONFIG_PATH)
3022
3312
  except Exception as exc:
3023
- log.error("初始化项目仓库失败: %s", exc)
3313
+ log.error("Failed to initialize project repository: %s", exc)
3024
3314
  sys.exit(1)
3025
3315
 
3026
3316
  records = repository.list_projects()
3027
3317
  if not records:
3028
- log.warning("项目配置为空,将以空项目列表启动。")
3318
+ log.warning("The project configuration is empty and will start with an empty project list. ")
3029
3319
 
3030
- configs = [ProjectConfig.from_dict(record.to_dict()) for record in records]
3320
+ configs= [ProjectConfig.from_dict(record.to_dict()) for record in records]
3031
3321
 
3032
3322
  state_store = StateStore(STATE_PATH, {cfg.project_slug: cfg for cfg in configs})
3033
3323
  manager = MasterManager(configs, state_store=state_store)
3034
3324
 
3035
3325
  await manager.stop_all(update_state=True)
3036
- log.info("已清理历史 tmux 会话,worker 需手动启动。")
3326
+ log.info("The historical tmux session has been cleared, and the worker needs to be started manually. ")
3037
3327
 
3038
3328
  global MANAGER
3039
3329
  global PROJECT_REPOSITORY
@@ -3043,13 +3333,13 @@ async def bootstrap_manager() -> MasterManager:
3043
3333
 
3044
3334
 
3045
3335
  async def main() -> None:
3046
- """master.py 的异步入口,完成 bot 启动与调度器绑定。"""
3336
+ """master.py The asynchronous entry completes the bot startup and binding to the scheduler. """
3047
3337
 
3048
3338
  manager = await bootstrap_manager()
3049
3339
 
3050
- # 诊断日志:记录重启信号文件路径,便于排查问题
3340
+ # Diagnosis log: record the restart signal file path to facilitate troubleshooting
3051
3341
  log.info(
3052
- "重启信号文件路径: %s (存在: %s)",
3342
+ "Restart signal file path: %s (Exists: %s)",
3053
3343
  RESTART_SIGNAL_PATH,
3054
3344
  RESTART_SIGNAL_PATH.exists(),
3055
3345
  extra={
@@ -3061,7 +3351,7 @@ async def main() -> None:
3061
3351
 
3062
3352
  master_token = os.environ.get("MASTER_BOT_TOKEN")
3063
3353
  if not master_token:
3064
- log.error("MASTER_BOT_TOKEN 未设置")
3354
+ log.error("MASTER_BOT_TOKEN not set")
3065
3355
  sys.exit(1)
3066
3356
 
3067
3357
  proxy_url, proxy_auth, _ = _detect_proxy()
@@ -3081,10 +3371,11 @@ async def main() -> None:
3081
3371
  dp.include_router(router)
3082
3372
  dp.startup.register(_notify_restart_success)
3083
3373
 
3084
- log.info("Master 已启动,监听管理员指令。")
3374
+ log.info("Master Started, listening for administrator commands. ")
3085
3375
  await _ensure_master_menu_button(bot)
3086
3376
  await _ensure_master_commands(bot)
3087
3377
  await _broadcast_master_keyboard(bot, manager)
3378
+ asyncio.create_task(_periodic_update_check(bot))
3088
3379
  await dp.start_polling(bot)
3089
3380
 
3090
3381
 
@@ -3093,4 +3384,4 @@ if __name__ == "__main__":
3093
3384
  try:
3094
3385
  asyncio.run(main())
3095
3386
  except KeyboardInterrupt:
3096
- log.info("Master 停止")
3387
+ log.info("Master stop")