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.
- bot.py +1557 -1431
- logging_setup.py +25 -18
- master.py +799 -508
- project_repository.py +42 -40
- scripts/__init__.py +1 -2
- scripts/bump_version.sh +57 -55
- scripts/log_writer.py +19 -16
- scripts/master_healthcheck.py +44 -44
- scripts/models/claudecode.sh +4 -4
- scripts/models/codex.sh +1 -1
- scripts/models/common.sh +24 -6
- scripts/models/gemini.sh +2 -2
- scripts/publish.sh +50 -50
- scripts/run_bot.sh +38 -17
- scripts/start.sh +136 -116
- scripts/start_tmux_codex.sh +8 -8
- scripts/stop_all.sh +21 -21
- scripts/stop_bot.sh +31 -10
- scripts/test_deps_check.sh +32 -28
- tasks/__init__.py +1 -1
- tasks/commands.py +4 -4
- tasks/constants.py +1 -1
- tasks/fsm.py +9 -9
- tasks/models.py +7 -7
- tasks/service.py +56 -56
- vibego-1.0.0.dist-info/METADATA +236 -0
- {vibego-0.2.52.dist-info → vibego-1.0.0.dist-info}/RECORD +36 -35
- vibego-1.0.0.dist-info/licenses/LICENSE +201 -0
- vibego_cli/__init__.py +5 -4
- vibego_cli/__main__.py +1 -2
- vibego_cli/config.py +9 -9
- vibego_cli/deps.py +8 -9
- vibego_cli/main.py +63 -63
- vibego-0.2.52.dist-info/METADATA +0 -197
- {vibego-0.2.52.dist-info → vibego-1.0.0.dist-info}/WHEEL +0 -0
- {vibego-0.2.52.dist-info → vibego-1.0.0.dist-info}/entry_points.txt +0 -0
- {vibego-0.2.52.dist-info → vibego-1.0.0.dist-info}/top_level.txt +0 -0
master.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Master bot controller.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
74
|
-
2.
|
|
75
|
-
3.
|
|
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
|
-
|
|
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
|
-
|
|
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")) #
|
|
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 =
|
|
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", "
|
|
124
|
-
("projects", "
|
|
125
|
-
("run", "
|
|
126
|
-
("stop", "
|
|
127
|
-
("switch", "
|
|
128
|
-
("authorize", "
|
|
129
|
-
("restart", "
|
|
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, ...] = ("
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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("
|
|
239
|
+
log.warning("Failed to set chat menu: %s", exc)
|
|
196
240
|
else:
|
|
197
|
-
log.info("
|
|
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
|
-
"""
|
|
202
|
-
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("
|
|
260
|
+
log.warning("Set master command failed: %s", exc, extra={"scope": label})
|
|
217
261
|
else:
|
|
218
|
-
log.info("master
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
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("
|
|
280
|
+
log.info("Startup broadcast disabled, skipping master keyboard push. ")
|
|
237
281
|
return
|
|
238
282
|
if not targets:
|
|
239
|
-
log.info("
|
|
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("
|
|
294
|
+
log.warning("Push menu disabled: %s", exc, extra={"chat": chat_id})
|
|
251
295
|
except TelegramBadRequest as exc:
|
|
252
|
-
log.warning("
|
|
296
|
+
log.warning("Push menu failed: %s", exc, extra={"chat": chat_id})
|
|
253
297
|
except Exception as exc:
|
|
254
|
-
log.error("
|
|
298
|
+
log.error("Push menu exception: %s", exc, extra={"chat": chat_id})
|
|
255
299
|
else:
|
|
256
|
-
log.info("
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
309
|
+
"""Normalize query project running status to avoid misuse of FSMContext.
|
|
266
310
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
"""
|
|
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("
|
|
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("
|
|
372
|
+
log.warning("Forced termination of master process failed: %s", exc, extra={"pid": pid})
|
|
329
373
|
if existing:
|
|
330
|
-
log.info("
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
454
|
+
"""read .env file and returns a key-value map. """
|
|
411
455
|
|
|
412
|
-
result:
|
|
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("
|
|
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
|
-
"""
|
|
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("
|
|
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
|
-
"""
|
|
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"⛔️
|
|
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"▶️
|
|
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="🚀
|
|
556
|
+
InlineKeyboardButton(text="🚀 Start all projects", callback_data="project:start_all:*")
|
|
513
557
|
)
|
|
514
558
|
builder.row(
|
|
515
|
-
InlineKeyboardButton(text="⛔️
|
|
559
|
+
InlineKeyboardButton(text="⛔️ Stop all projects", callback_data="project:stop_all:*")
|
|
516
560
|
)
|
|
517
561
|
builder.row(
|
|
518
|
-
InlineKeyboardButton(text="🔄
|
|
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("
|
|
566
|
+
log.info("Project overview generated button count=%s", button_count)
|
|
523
567
|
if button_count == 0:
|
|
524
|
-
return
|
|
525
|
-
|
|
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("
|
|
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
|
-
"""
|
|
826
|
+
"""Convert an arbitrary string into a short tag usable by project_slug. """
|
|
565
827
|
|
|
566
|
-
slug
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
895
|
+
"""Represents the current running status of the project, which is persisted by StateStore. """
|
|
634
896
|
|
|
635
|
-
model:
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
"""
|
|
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("
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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"
|
|
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
|
-
"""
|
|
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"
|
|
1028
|
+
raise ValueError(f"Unknown item {slug}")
|
|
767
1029
|
return cfg
|
|
768
1030
|
|
|
769
1031
|
def _resolve_project(self, identifier: str) -> Optional[ProjectConfig]:
|
|
770
|
-
"""
|
|
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("@"): #
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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"
|
|
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"
|
|
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("
|
|
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"
|
|
1142
|
+
issues.append(f"Model command {executable} not detected, please confirm it is installed")
|
|
881
1143
|
elif model_lower != "gemini":
|
|
882
|
-
issues.append("
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
1322
|
+
issues = [f"run_bot.sh Exit code {rc}"]
|
|
1061
1323
|
if tail_lines:
|
|
1062
|
-
issues.append("
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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("
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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("
|
|
1396
|
+
self._format_issue_message("Some projects failed to start", errors)
|
|
1135
1397
|
)
|
|
1136
1398
|
|
|
1137
1399
|
async def restore_running(self) -> None:
|
|
1138
|
-
"""
|
|
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("
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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"
|
|
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
|
-
"
|
|
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": "
|
|
1212
|
-
"bot_token": "
|
|
1213
|
-
"project_slug": "
|
|
1214
|
-
"default_model": "
|
|
1215
|
-
"workdir": "
|
|
1216
|
-
"allowed_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": "
|
|
1220
|
-
"bot_token": "
|
|
1221
|
-
"project_slug": "
|
|
1222
|
-
"default_model": "
|
|
1223
|
-
"workdir": "
|
|
1224
|
-
"allowed_chat_id": "
|
|
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
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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"
|
|
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"
|
|
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
|
-
"""
|
|
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("
|
|
1404
|
-
await message.answer(f"
|
|
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
|
|
1408
|
-
chat_desc
|
|
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
|
|
1412
|
-
f"slug
|
|
1413
|
-
f"
|
|
1414
|
-
f"
|
|
1415
|
-
f"chat_id
|
|
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}\
|
|
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(
|
|
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("
|
|
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(
|
|
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
|
|
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
|
-
#
|
|
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("
|
|
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("
|
|
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"
|
|
1602
|
-
f"
|
|
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 {"
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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:
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
"
|
|
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) #
|
|
1960
|
+
time.sleep(0.1) # Wait 100ms and try again
|
|
1693
1961
|
else:
|
|
1694
1962
|
log.error(
|
|
1695
|
-
"
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
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("
|
|
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("
|
|
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
|
-
"""
|
|
1776
|
-
restart_expected
|
|
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
|
-
#
|
|
1780
|
-
RESTART_HEALTHY_THRESHOLD = 120
|
|
1781
|
-
RESTART_WARNING_THRESHOLD = 60
|
|
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
|
-
"
|
|
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⚠️
|
|
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.
|
|
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"-
|
|
1813
|
-
f"-
|
|
1814
|
-
f"-
|
|
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("
|
|
2091
|
+
log.info("The complete restart notification has been sent", extra={"chat": chat})
|
|
1824
2092
|
except Exception as exc:
|
|
1825
|
-
log.error("
|
|
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("
|
|
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("
|
|
2129
|
+
log.warning("Failed to parse restart time: %s", exc)
|
|
1862
2130
|
|
|
1863
2131
|
details = []
|
|
1864
2132
|
if username:
|
|
1865
|
-
details.append(f"
|
|
2133
|
+
details.append(f"Trigger: @{username}")
|
|
1866
2134
|
elif user_id:
|
|
1867
|
-
details.append(f"
|
|
2135
|
+
details.append(f"Trigger ID: {user_id}")
|
|
1868
2136
|
if timestamp_fmt:
|
|
1869
|
-
details.append(f"
|
|
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
|
|
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
|
|
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
|
|
1881
|
-
details.append("⚠️
|
|
1882
|
-
details.append(" -
|
|
1883
|
-
details.append(" -
|
|
1884
|
-
details.append(f" -
|
|
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("
|
|
2164
|
+
log.error("Failed to send restart successful notification: %s", exc, extra={"chat": chat_id})
|
|
1897
2165
|
else:
|
|
1898
|
-
#
|
|
1899
|
-
log.info("
|
|
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
|
-
"""
|
|
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:
|
|
2186
|
+
message: message,
|
|
1919
2187
|
*,
|
|
1920
2188
|
trigger_user: Optional[User] = None,
|
|
1921
2189
|
manager: Optional[MasterManager] = None,
|
|
1922
2190
|
) -> None:
|
|
1923
|
-
"""
|
|
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
|
-
|
|
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("
|
|
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("
|
|
2219
|
+
log.error("Record restart signal exception: %s", exc)
|
|
1952
2220
|
|
|
1953
2221
|
notice = (
|
|
1954
|
-
"
|
|
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
|
-
"""
|
|
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
|
|
1979
|
-
f"
|
|
1980
|
-
"
|
|
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
|
-
"""
|
|
1993
|
-
|
|
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="
|
|
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("
|
|
2276
|
+
log.warning("Failed to send startup notification: %s", notice_exc)
|
|
2009
2277
|
try:
|
|
2010
|
-
#
|
|
2011
|
-
proc
|
|
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("
|
|
2286
|
+
log.info("start triggered.sh Restart, pid=%s", proc.pid if proc else "-")
|
|
2019
2287
|
except Exception as exc:
|
|
2020
|
-
log.error("
|
|
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"
|
|
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("
|
|
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("
|
|
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
|
-
"""
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
"""
|
|
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
|
|
2136
|
-
await message.answer(f"{action}
|
|
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("
|
|
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("
|
|
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
|
-
#
|
|
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("
|
|
2487
|
+
await callback.answer("Unknown project", show_alert=True)
|
|
2198
2488
|
return
|
|
2199
2489
|
|
|
2200
|
-
#
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
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"🧠
|
|
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"
|
|
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("
|
|
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 = "
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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"
|
|
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("
|
|
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("
|
|
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("
|
|
2625
|
+
log.info("Button operation successful: user=%s Stop all projects", user_id)
|
|
2336
2626
|
elif action == "start_all":
|
|
2337
|
-
#
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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("
|
|
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"
|
|
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
|
-
"
|
|
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"
|
|
2714
|
+
message_text = f"All project models have been switched to {label} and remain stopped. "
|
|
2425
2715
|
log.info(
|
|
2426
|
-
"
|
|
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("
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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"
|
|
2761
|
+
await callback.answer(f"Switched to {model_map.get(chosen, chosen)}")
|
|
2472
2762
|
log.info(
|
|
2473
|
-
"
|
|
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"
|
|
2770
|
+
await callback.answer(f"The default model has been updated to {model_map.get(chosen, chosen)}")
|
|
2481
2771
|
log.info(
|
|
2482
|
-
"
|
|
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("
|
|
2779
|
+
await callback.answer("Unknown operation", show_alert=True)
|
|
2490
2780
|
return
|
|
2491
2781
|
except Exception as exc:
|
|
2492
2782
|
log.error(
|
|
2493
|
-
"
|
|
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"
|
|
2501
|
-
await callback.answer("
|
|
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
|
-
"""
|
|
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
|
|
2806
|
+
parts=message.text.split()
|
|
2517
2807
|
if len(parts) < 2:
|
|
2518
|
-
await message.answer("
|
|
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
|
-
"""
|
|
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"
|
|
2822
|
+
return f"Started {cfg.display_name} (model={chosen})"
|
|
2533
2823
|
|
|
2534
|
-
await _run_and_reply(message, "
|
|
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
|
-
"""
|
|
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
|
|
2836
|
+
parts=message.text.split()
|
|
2547
2837
|
if len(parts) < 2:
|
|
2548
|
-
await message.answer("
|
|
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"
|
|
2851
|
+
return f"Stopped {cfg.display_name}"
|
|
2562
2852
|
|
|
2563
|
-
await _run_and_reply(message, "
|
|
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
|
-
"""
|
|
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
|
|
2865
|
+
parts=message.text.split()
|
|
2576
2866
|
if len(parts) < 3:
|
|
2577
|
-
await message.answer("
|
|
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"
|
|
2881
|
+
return f"Switched {cfg.display_name} to {chosen}"
|
|
2592
2882
|
|
|
2593
|
-
await _run_and_reply(message, "
|
|
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
|
-
"""
|
|
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
|
|
2895
|
+
parts=message.text.split()
|
|
2606
2896
|
if len(parts) < 3:
|
|
2607
|
-
await message.answer("
|
|
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"
|
|
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("
|
|
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("
|
|
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("
|
|
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="➕
|
|
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"⚙️
|
|
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"🧠
|
|
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="📂
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
3089
|
+
await callback.answer("Not authorized.", show_alert=True)
|
|
2800
3090
|
return
|
|
2801
3091
|
if callback.message is None:
|
|
2802
|
-
await callback.answer("
|
|
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("
|
|
3096
|
+
await callback.answer("Invalid operation", show_alert=True)
|
|
2807
3097
|
return
|
|
2808
3098
|
target_slug = parts[2]
|
|
2809
3099
|
log.info(
|
|
2810
|
-
"
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
"
|
|
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("
|
|
2861
|
-
await callback.message.answer(f"
|
|
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"
|
|
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("
|
|
3171
|
+
await callback.answer("Not authorized.", show_alert=True)
|
|
2882
3172
|
return
|
|
2883
3173
|
if callback.message is None:
|
|
2884
|
-
await callback.answer("
|
|
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("
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
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"
|
|
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
|
|
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 = {"
|
|
2952
|
-
cancel_tokens = {"
|
|
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"
|
|
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
|
-
"
|
|
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"
|
|
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"
|
|
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
|
-
"""
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
"
|
|
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")
|