-
Notifications
You must be signed in to change notification settings - Fork 601
Expand file tree
/
Copy pathbot.py
More file actions
553 lines (440 loc) · 19 KB
/
Copy pathbot.py
File metadata and controls
553 lines (440 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# raise RuntimeError("System Not Ready")
from pathlib import Path
from rich.traceback import install
from typing import TypeVar
import asyncio
import hashlib
import os
import platform
# import shutil
import signal
import subprocess
import sys
import time
import traceback
from src.common.i18n import set_locale, t, tn
from src.common.logger import get_logger, initialize_logging, shutdown_logging
from src.common.runtime_loop import set_main_loop
from src.common.shutdown import request_shutdown
from src.common.update_notice import emit_terminal_update_notice_if_needed
from src.config.legacy_upgrade_confirmation import require_legacy_upgrade_confirmation
# 设置工作目录为脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
set_locale(os.getenv("MAIBOT_LOCALE", "zh-CN"))
# 检查是否是 Worker 进程,只在 Worker 进程中输出详细的初始化信息
# Runner 进程只需要基本的日志功能,不需要详细的初始化日志
is_worker = os.environ.get("MAIBOT_WORKER_PROCESS") == "1"
initialize_logging(verbose=is_worker)
install(extra_lines=3)
logger = get_logger("main")
# 定义重启退出码
RESTART_EXIT_CODE = 42
_active_main_loop: asyncio.AbstractEventLoop | None = None
_active_main_task: asyncio.Task[None] | None = None
_shutdown_signal_count: int = 0
_RunResultT = TypeVar("_RunResultT")
# print("-----------------------------------------")
# print("\n\n\n\n\n")
# print(t("startup.dev_branch_warning"))
# print("\n\n\n\n\n")
# print("-----------------------------------------")
def _print_interrupt_exit_notice() -> None:
"""在日志系统不可用或正在退出时,用最小输出提示 Ctrl+C 退出。"""
print("\n收到 Ctrl+C,中断退出。")
def _mark_shutdown_and_interrupt(_signum: int, _frame: object) -> None:
"""收到中断信号时标记关停,并请求主任务取消。"""
global _shutdown_signal_count
_shutdown_signal_count += 1
request_shutdown("signal")
main_loop = _active_main_loop
if main_loop is None or main_loop.is_closed():
return
try:
main_loop.call_soon_threadsafe(_cancel_active_main_task_from_signal)
except RuntimeError:
return
def _cancel_active_main_task_from_signal() -> None:
"""在事件循环线程中取消当前主任务。"""
if _active_main_task is None or _active_main_task.done():
return
_active_main_task.cancel()
def run_runner_process():
"""
Runner 进程逻辑:作为守护进程运行,负责启动和监控 Worker 进程。
处理重启请求 (退出码 42) 和 Ctrl+C 信号。
"""
script_file = sys.argv[0]
python_executable = sys.executable
# 设置环境变量,标记子进程为 Worker 进程
env = os.environ.copy()
env["MAIBOT_WORKER_PROCESS"] = "1"
while True:
logger.info(t("startup.launching_script", script_file=script_file))
# 启动子进程 (Worker)
# 使用 sys.executable 确保使用相同的 Python 解释器
cmd = [python_executable, script_file] + sys.argv[1:]
process = subprocess.Popen(cmd, env=env)
try:
# 等待子进程结束
return_code = process.wait()
if return_code == RESTART_EXIT_CODE:
logger.info(t("startup.restart_requested", exit_code=RESTART_EXIT_CODE))
time.sleep(1) # 稍作等待
continue
else:
logger.info(t("startup.program_exited", return_code=return_code))
sys.exit(return_code)
except KeyboardInterrupt:
# 向子进程发送终止信号
if process.poll() is None:
# 在 Windows 上,Ctrl+C 通常已经发送给了子进程(如果它们共享控制台)
# 但为了保险,我们可以尝试 terminate
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning(t("startup.child_process_force_kill"))
process.kill()
sys.exit(0)
# 检查是否是 Worker 进程
# 如果没有设置 MAIBOT_WORKER_PROCESS 环境变量,说明是直接运行的脚本,
# 此时应该作为 Runner 运行。
if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
if __name__ == "__main__":
require_legacy_upgrade_confirmation(Path(script_dir))
run_runner_process()
# 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑
sys.exit(0)
# 以下是 Worker 进程的逻辑
# 最早期初始化日志系统,确保所有后续模块都使用正确的日志格式
# 注意:Runner 进程已经在第 37 行初始化了日志系统,但 Worker 进程是独立进程,需要重新初始化
# 由于 Runner 和 Worker 是不同进程,它们有独立的内存空间,所以都会初始化一次
# 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制
# 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为
require_legacy_upgrade_confirmation(Path(script_dir))
asyncio.run(emit_terminal_update_notice_if_needed())
logger.info(t("startup.worker_dir_set", script_dir=script_dir))
from src.main import MainSystem # noqa
from src.manager.async_task_manager import async_task_manager # noqa
# logger = get_logger("main")
# install(extra_lines=3)
# 设置工作目录为脚本所在目录
# script_dir = os.path.dirname(os.path.abspath(__file__))
# os.chdir(script_dir)
confirm_logger = get_logger("confirm")
# 获取没有加载env时的环境变量
env_mask = {key: os.getenv(key) for key in os.environ}
uvicorn_server = None
driver = None
app = None
loop = None
def print_opensource_notice():
"""打印开源项目提示,防止倒卖"""
from colorama import init, Fore, Style
init()
notice_lines = [
"",
f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}",
f"{Fore.GREEN}{t('startup.opensource_title')}{Style.RESET_ALL}",
f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}",
f"{Fore.YELLOW}{t('startup.opensource_free_notice')}{Style.RESET_ALL}",
f"{Fore.WHITE}{t('startup.opensource_scamming_notice')}{Style.RESET_ALL}",
"",
f"{Fore.WHITE}{t('startup.opensource_repo')}{Fore.BLUE}{t('startup.opensource_repo_value')} {Style.RESET_ALL}",
f"{Fore.WHITE}{t('startup.opensource_docs')}{Fore.BLUE}{t('startup.opensource_docs_value')} {Style.RESET_ALL}",
f"{Fore.WHITE}{t('startup.opensource_group')}{Fore.BLUE}{t('startup.opensource_group_value')}{Style.RESET_ALL}",
f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}",
f"{Fore.RED} ⚠ {t('startup.opensource_resale_warning').strip()}{Style.RESET_ALL}",
f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}",
"",
]
for line in notice_lines:
print(line)
def easter_egg():
# 彩蛋
from colorama import init, Fore
init()
text = t("startup.easter_egg")
rainbow_colors = [Fore.RED, Fore.YELLOW, Fore.GREEN, Fore.CYAN, Fore.BLUE, Fore.MAGENTA]
rainbow_text = ""
for i, char in enumerate(text):
rainbow_text += rainbow_colors[i % len(rainbow_colors)] + char
print(rainbow_text)
async def graceful_shutdown(main_system: MainSystem | None = None): # sourcery skip: use-named-expression
try:
request_shutdown("graceful_shutdown")
logger.info(t("startup.shutdown_started"))
# 关闭 WebUI 服务器
try:
if main_system is not None and main_system.webui_server is not None:
await main_system.webui_server.shutdown()
except Exception as e:
logger.warning(f"关闭 WebUI 服务器时出错: {e}")
from src.core.event_bus import event_bus
from src.core.types import EventType
# 触发 ON_STOP 事件
await _await_shutdown_step(
event_bus.emit(event_type=EventType.ON_STOP),
timeout=5.0,
step_name="触发 ON_STOP 事件",
)
# 停止新版本插件运行时
from src.plugin_runtime.integration import get_plugin_runtime_manager
await _await_shutdown_step(
get_plugin_runtime_manager().stop(),
timeout=8.0,
step_name="停止插件运行时",
)
# 停止所有异步任务
await _await_shutdown_step(
async_task_manager.stop_and_wait_all_tasks(),
timeout=5.0,
step_name="停止异步任务管理器任务",
)
# 获取所有剩余任务,排除当前任务
remaining_tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
if remaining_tasks:
logger.info(tn("startup.remaining_tasks_cancelling", len(remaining_tasks)))
# 取消所有剩余任务
for task in remaining_tasks:
if not task.done():
task.cancel()
# 等待所有任务完成,设置超时
try:
await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=5.0)
logger.info(t("startup.remaining_tasks_cancelled"))
except asyncio.TimeoutError:
logger.warning(t("startup.remaining_tasks_cancel_timeout"))
except Exception as e:
logger.error(t("startup.remaining_tasks_cancel_error", error=e))
logger.info(t("startup.shutdown_completed"))
except Exception as e:
logger.error(t("startup.shutdown_failed", error=e), exc_info=True)
async def _await_shutdown_step(awaitable, *, timeout: float, step_name: str):
"""为关停步骤设置硬超时,避免单个组件阻塞 Ctrl+C 退出。"""
try:
return await asyncio.wait_for(awaitable, timeout=timeout)
except asyncio.TimeoutError:
logger.warning(f"{step_name} 超时,继续执行后续关停步骤")
return None
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(f"{step_name} 失败,继续执行后续关停步骤: {exc}", exc_info=True)
return None
def _cancel_main_task(main_loop: asyncio.AbstractEventLoop | None, main_task: asyncio.Task[None] | None) -> None:
"""取消主调度任务,并等待取消结果落地。"""
if main_loop is None or main_task is None or main_task.done() or main_loop.is_closed():
return
main_task.cancel()
try:
_run_until_complete(main_loop, main_task)
except asyncio.CancelledError:
pass
def _is_windows_proactor_cancel_race(error: BaseException) -> bool:
"""判断是否为 Windows Proactor 在连接取消时产生的事件循环内部竞态。"""
if sys.platform != "win32" or not isinstance(error, asyncio.InvalidStateError):
return False
return any(
frame.f_code.co_filename.replace("\\", "/").lower().endswith("/asyncio/windows_events.py")
for frame, _ in traceback.walk_tb(error.__traceback__)
)
def _run_until_complete(
main_loop: asyncio.AbstractEventLoop,
future: asyncio.Future[_RunResultT],
) -> _RunResultT:
"""运行 Future;在 Windows Proactor 瞬时状态竞争时继续驱动事件循环。"""
while not future.done():
try:
main_loop.run_until_complete(future)
except asyncio.InvalidStateError as e:
if not _is_windows_proactor_cancel_race(e):
raise
logger.debug("忽略 Windows Proactor 瞬时 InvalidStateError,继续运行事件循环。", exc_info=True)
return future.result()
def _run_graceful_shutdown(
main_loop: asyncio.AbstractEventLoop | None,
main_system: MainSystem | None,
) -> bool:
"""在同步入口中执行异步优雅关闭。"""
if main_loop is None or main_loop.is_closed():
return False
try:
shutdown_task = main_loop.create_task(graceful_shutdown(main_system))
_run_until_complete(main_loop, shutdown_task)
return True
except KeyboardInterrupt:
_print_interrupt_exit_notice()
except Exception as ge:
logger.error(t("startup.graceful_shutdown_error", error=ge))
return False
def _calculate_file_hash(file_path: Path, file_type: str) -> str:
"""计算文件的MD5哈希值"""
if not file_path.exists():
logger.error(t("startup.file_not_found", file_type=file_type))
raise FileNotFoundError(t("startup.file_not_found", file_type=file_type))
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
return hashlib.md5(content.encode("utf-8")).hexdigest()
def _check_agreement_status(file_hash: str, confirm_file: Path, env_var: str) -> tuple[bool, bool]:
"""检查协议确认状态
Returns:
tuple[bool, bool]: (已确认, 未更新)
"""
# 检查环境变量确认
if file_hash == os.getenv(env_var):
return True, False
# 检查确认文件
if confirm_file.exists():
with open(confirm_file, "r", encoding="utf-8") as f:
confirmed_content = f.read()
if file_hash == confirmed_content:
return True, False
return False, True
def _prompt_user_confirmation(eula_hash: str, privacy_hash: str) -> None:
"""提示用户确认协议"""
confirm_logger.critical(t("startup.agreement_reconfirm"))
confirm_logger.critical(
t(
"startup.agreement_confirm_prompt",
eula_hash=eula_hash,
privacy_hash=privacy_hash,
)
)
while True:
user_input = input().strip().lower()
if user_input in ["同意", "confirmed"]:
return
confirm_logger.critical(t("startup.agreement_confirm_retry"))
def _save_confirmations(eula_updated: bool, privacy_updated: bool, eula_hash: str, privacy_hash: str) -> None:
"""保存用户确认结果"""
if eula_updated:
logger.info(
t(
"startup.agreement_updated",
agreement_name=t("startup.eula_name"),
file_hash=eula_hash,
)
)
Path("eula.confirmed").write_text(eula_hash, encoding="utf-8")
if privacy_updated:
logger.info(
t(
"startup.agreement_updated",
agreement_name=t("startup.privacy_name"),
file_hash=privacy_hash,
)
)
Path("privacy.confirmed").write_text(privacy_hash, encoding="utf-8")
def check_eula():
"""检查EULA和隐私条款确认状态"""
# 计算文件哈希值
eula_hash = _calculate_file_hash(Path("EULA.md"), "EULA.md")
privacy_hash = _calculate_file_hash(Path("PRIVACY.md"), "PRIVACY.md")
# 检查确认状态
eula_confirmed, eula_updated = _check_agreement_status(eula_hash, Path("eula.confirmed"), "EULA_AGREE")
privacy_confirmed, privacy_updated = _check_agreement_status(
privacy_hash, Path("privacy.confirmed"), "PRIVACY_AGREE"
)
# 早期返回:如果都已确认且未更新
if eula_confirmed and privacy_confirmed:
return
# 如果有更新,需要重新确认
if eula_updated or privacy_updated:
_prompt_user_confirmation(eula_hash, privacy_hash)
_save_confirmations(eula_updated, privacy_updated, eula_hash, privacy_hash)
def raw_main():
# 利用 TZ 环境变量设定程序工作的时区
if platform.system().lower() != "windows":
time.tzset() # type: ignore
# 打印开源提示(防止倒卖)
print_opensource_notice()
check_eula()
logger.info(t("startup.eula_privacy_checked"))
easter_egg()
# 返回MainSystem实例
return MainSystem()
if __name__ == "__main__":
exit_code = 0 # 用于记录程序最终的退出状态
main_system: MainSystem | None = None
main_tasks: asyncio.Task[None] | None = None
shutdown_completed = False
try:
# 获取MainSystem实例
main_system = raw_main()
# 创建事件循环
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
set_main_loop(loop)
_active_main_loop = loop
signal.signal(signal.SIGINT, _mark_shutdown_and_interrupt)
# 初始化 WebSocket 日志推送
from src.common.logger import initialize_ws_handler
initialize_ws_handler(loop)
try:
# 执行初始化和任务调度
initialize_task = loop.create_task(main_system.initialize())
_active_main_task = initialize_task
_run_until_complete(loop, initialize_task)
main_tasks = loop.create_task(main_system.schedule_tasks())
_active_main_task = main_tasks
_run_until_complete(loop, main_tasks)
except KeyboardInterrupt:
request_shutdown("keyboard_interrupt")
try:
logger.warning(t("startup.interrupt_received"))
except KeyboardInterrupt:
raise
# 取消主任务
_cancel_main_task(loop, main_tasks)
# 执行优雅关闭
shutdown_completed = _run_graceful_shutdown(loop, main_system)
except asyncio.CancelledError:
request_shutdown("task_cancelled")
try:
logger.warning(t("startup.interrupt_received"))
except KeyboardInterrupt:
pass
shutdown_completed = _run_graceful_shutdown(loop, main_system)
# 新增:检测外部请求关闭
except SystemExit as e:
# 捕获 SystemExit (例如 sys.exit()) 并保留退出代码
if isinstance(e.code, int):
exit_code = e.code
else:
exit_code = 1 if e.code else 0
if exit_code == RESTART_EXIT_CODE:
logger.info(t("startup.restart_signal_received"))
except KeyboardInterrupt:
request_shutdown("keyboard_interrupt")
_print_interrupt_exit_notice()
except Exception as e:
try:
logger.error(t("startup.main_error", error=f"{str(e)} {str(traceback.format_exc())}"))
except KeyboardInterrupt:
_print_interrupt_exit_notice()
if not shutdown_completed:
_cancel_main_task(loop, main_tasks)
shutdown_completed = _run_graceful_shutdown(loop, main_system)
exit_code = 1 # 标记发生错误
finally:
try:
# 确保 loop 在任何情况下都尝试关闭(如果存在且未关闭)
if "loop" in locals() and loop and not loop.is_closed():
_active_main_task = None
_active_main_loop = None
set_main_loop(None)
loop.close()
print(t("startup.event_loop_closed"))
# 关闭日志系统,释放文件句柄
try:
shutdown_logging()
except Exception as e:
print(t("startup.logging_shutdown_error", error=e))
print(t("startup.prepare_exit"))
except KeyboardInterrupt:
_print_interrupt_exit_notice()
# 使用 os._exit() 强制退出,避免被阻塞
# 由于已经在 graceful_shutdown() 中完成了所有清理工作,这是安全的
os._exit(exit_code)