Ctrl K
ComunidadGuías…

src/webui/bootstrap.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""WebUI startup, background-task, and cleanup lifecycle."""

from __future__ import annotations

import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from pathlib import Path
import signal
from typing import Any

from aiohttp import web

from src.docker_launcher.data_versions import (
    activation_pending, prune_retired_data, wait_for_activation,
)
from src.ai_providers import resolve_provider
from src.adventures import sync_adventure_catalog
from src.common_factory import TRPGSubsystems, close_model_clients
from src.hub_client import HubClient
from src.plugin_host import PluginHost
from src.template_catalog import sync_template_catalog
from src.web_transport import ServerTransport
from src.webui.assistant_knowledge import prefetch_remote_indexes
from src.webui.host_credentials import HostCredentials
from src.webui.services import legal as legal_svc
from src.webui.services import updater as updater_svc


@dataclass(frozen=True)
class BootstrapPaths:
    root: Path
    data_dir: Path
    builtin_rules_dir: Path
    builtin_worlds_dir: Path
    builtin_adventures_dir: Path
    rules_dir: Path
    worlds_dir: Path
    adventures_dir: Path


@dataclass(frozen=True)
class BootstrapDependencies:
    paths: BootstrapPaths
    state: dict
    environ: Mapping[str, str]
    transport: ServerTransport
    generation_defaults_migrated: bool
    credentials: Callable[[], HostCredentials]
    save_config: Callable[[], None]
    build_subsystems: Callable[..., TRPGSubsystems]
    make_api: Callable[..., Any]
    activate_api_runtime: Callable[[TRPGSubsystems, Any], None]


class WebUIBootstrap:
    def __init__(
        self,
        dependencies: BootstrapDependencies,
        *,
        logger: logging.Logger | None = None,
    ) -> None:
        self.dependencies = dependencies
        self.logger = logger or logging.getLogger("trpg")

    async def periodic_save(self, app: web.Application) -> None:
        while True:
            await asyncio.sleep(60)
            subsystems: TRPGSubsystems | None = app.get("subsystems")
            if subsystems:
                try:
                    await subsystems.registry.save_all_active()
                except Exception:
                    self.logger.exception("定时保存失败")

    async def embed_pending_memories(self, app: web.Application) -> None:
        state = self.dependencies.state
        if not state.get("embedding_enabled", False):
            return
        subsystems: TRPGSubsystems | None = app.get("subsystems")
        if not subsystems or not subsystems.memory_store or not subsystems.memory_store.embedding_client:
            return
        try:
            for instance in subsystems.registry.list_all():
                count = await subsystems.memory_store.embed_all_pending(
                    str(instance.game_key)
                )
                if count:
                    self.logger.info(
                        "[Embedding] %s: backfilled %d pending memories",
                        instance.world_name,
                        count,
                    )
        except asyncio.CancelledError:
            raise
        except Exception:
            self.logger.exception("Embedding backfill failed")

    async def on_startup(self, app: web.Application) -> None:
        dependencies = self.dependencies
        state = dependencies.state
        self.sync_builtin_templates()
        if dependencies.generation_defaults_migrated:
            dependencies.save_config()
            self.logger.warning("已迁移 generation 默认值到新版本配置")
        dependencies.credentials().initialize_access_password()
        dependencies.credentials().ensure_bot_token()
        subsystems = dependencies.build_subsystems()
        app["subsystems"] = subsystems
        hub_client = None
        try:
            legal_accepted = legal_svc.accepted(state)
            hub_client = HubClient(
                dependencies.paths.data_dir,
                telemetry_enabled=bool(state.get("hub_telemetry_enabled"))
                and legal_accepted,
                telemetry_choice_made=bool(
                    state.get("hub_telemetry_choice_made")
                )
                and legal_accepted,
            )
            await hub_client.start()
        except ValueError as exc:
            self.logger.warning("DiceFrame Hub 配置无效,已停用 Hub 接入:%s", exc)
        app["hub_client"] = hub_client

        async def on_plugin_stopped(plugin_id: str) -> None:
            api = app.get("api")
            if api is not None:
                api.release_tunnel_url(plugin_id)

        plugin_host = PluginHost(
            dependencies.paths.data_dir / "plugin-packages",
            dependencies.paths.data_dir / "plugins",
            builtin_dir=dependencies.paths.root / "plugins",
            base_env={
                # 跟随实际监听地址族:纯 IPv6 监听时为 [::1],避免插件连不上
                # 根本没在监听的 127.0.0.1。
                "TRPG_API_BASE": dependencies.transport.endpoint.url()
            },
            on_plugin_stopped=on_plugin_stopped,
            hub_client=hub_client,
            ai_provider_resolver=lambda provider_id: resolve_provider(
                state, provider_id
            ),
        )
        self._migrate_portable_plugin_packages(plugin_host)
        plugin_host.discover()
        if "qq-napcat" in plugin_host.plugins:
            plugin_host.migrate_config(
                "qq-napcat",
                {
                    "enabled": state.get("qq_bot_enabled", False),
                    "host": state.get("napcat_host"),
                    "port": state.get("napcat_port"),
                    "token": state.get("napcat_token"),
                    "heartbeat_sec": state.get("napcat_heartbeat_sec"),
                    "reconnect_delay_sec": state.get(
                        "napcat_reconnect_delay_sec"
                    ),
                    "action_timeout_sec": state.get("napcat_action_timeout_sec"),
                    "reply_delay_min_sec": state.get("napcat_reply_delay_min_sec"),
                    "reply_delay_max_sec": state.get("napcat_reply_delay_max_sec"),
                    "command_dedup_window_sec": state.get(
                        "napcat_command_dedup_window_sec"
                    ),
                    "connection_id": state.get("napcat_connection_id"),
                    "chat_filter_enabled": state.get(
                        "napcat_chat_filter_enabled"
                    ),
                    "show_dropped_logs": state.get("napcat_show_dropped_logs"),
                    "group_list_mode": state.get("napcat_group_list_mode"),
                    "group_list": state.get("napcat_group_list"),
                    "private_list_mode": state.get("napcat_private_list_mode"),
                    "private_list": state.get("napcat_private_list"),
                    "blocked_users": state.get("napcat_blocked_users"),
                    "block_official_bots": state.get(
                        "napcat_block_official_bots"
                    ),
                },
            )
        app["plugin_host"] = plugin_host
        app["api"] = dependencies.make_api(
            subsystems,
            plugin_host,
            hub_client=hub_client,
        )
        # FIX-01 §3.3/§3.5:把安装事务内的包校验与 bound-save guard 接到宿主上,
        # 所有能改 package bytes 的路径(本地导入 / 市场安装 / 覆盖 / 后台自动
        # 更新 / 卸载)共用同一条 choke point。
        app["api"].attach_package_hooks(plugin_host)
        # FIX-07 §10:启动即刷新一次内容来源。否则"重启后直接开局"会在任何人
        # 读过冒险/模组页面之前解析不到已启用模组的冒险包。
        app["api"].refresh_content_sources()
        dependencies.activate_api_runtime(subsystems, app["api"])
        app["updater"] = updater_svc.UpdaterService(
            updater_svc.UpdaterDependencies(
                data_dir=dependencies.paths.data_dir,
                root=dependencies.paths.root,
                mirrors=plugin_host.mirrors if plugin_host else None,
                check_updates=app["api"].check_updates,
            )
        )
        recovered = await subsystems.registry.recover_all()
        if recovered:
            self.logger.info("恢复了 %d 个存档", len(recovered))
        removed_templates = app["api"].cleanup_orphan_game_templates()
        if removed_templates:
            self.logger.info(
                "已清理 %d 个孤立的对局临时世界模板", removed_templates
            )
        if activation_pending(dependencies.environ):
            app["_data_activation_task"] = asyncio.create_task(
                self.activate_after_update(app, recovered), name="update-data-activation",
            )
        else:
            await self.start_background(app, recovered)

    async def activate_after_update(self, app: web.Application, recovered) -> None:
        await wait_for_activation(self.dependencies.environ)
        runtime = Path(self.dependencies.environ["TRPG_DOCKER_RUNTIME_ROOT"]).resolve()
        try:
            prune_retired_data(runtime)
        except (OSError, ValueError):
            self.logger.warning("保留无法安全清理的旧更新数据副本", exc_info=True)
        await self.start_background(app, recovered)

    async def start_background(self, app: web.Application, recovered) -> None:
        if recovered:
            delivered = await app["api"].recover_economy_outboxes(recovered)
            if delivered:
                self.logger.info("恢复了 %d 个对局的经济外部效果", delivered)
        await app["plugin_host"].start_enabled()
        app["_embedding_backfill_task"] = asyncio.create_task(
            self.embed_pending_memories(app)
        )
        app["_save_task"] = asyncio.create_task(self.periodic_save(app))
        app["_assistant_docs_task"] = asyncio.create_task(
            prefetch_remote_indexes()
        )
        app["_certificate_renewal_task"] = asyncio.create_task(
            self.certificate_renewal_loop(app),
            name="certificate-renewal",
        )

    def sync_builtin_templates(self) -> None:
        paths = self.dependencies.paths
        rule_sync = sync_template_catalog(
            paths.builtin_rules_dir,
            paths.rules_dir,
            "rules",
        )
        world_sync = sync_template_catalog(
            paths.builtin_worlds_dir,
            paths.worlds_dir,
            "worlds",
        )
        adventure_sync = sync_adventure_catalog(
            paths.builtin_adventures_dir,
            paths.adventures_dir,
        )
        if any(rule_sync.values()) or any(world_sync.values()) or any(
            adventure_sync.values()
        ):
            self.logger.info(
                "模板目录已同步到 data: rules=%s worlds=%s adventures=%s",
                rule_sync,
                world_sync,
                adventure_sync,
            )

    def _migrate_portable_plugin_packages(self, plugin_host: PluginHost) -> None:
        install_root = str(
            self.dependencies.environ.get("TRPG_INSTALL_ROOT") or ""
        ).strip()
        if not install_root:
            return
        from src.webui.services.updater import _migrate_user_plugin_packages

        install_root_path = Path(install_root)
        sources = [install_root_path / "app" / "plugins"]
        sources.extend(install_root_path.glob("versions/*/app/plugins"))
        for source in sources:
            _migrate_user_plugin_packages(
                source,
                self.dependencies.paths.data_dir / "plugin-packages",
            )

    async def certificate_renewal_loop(self, app: web.Application) -> None:
        while True:
            try:
                service = app.get("security_transport")
                result = await service.renew_if_due() if service else None
                if result and result.get("status") == "failed":
                    self.logger.error(
                        "Let's Encrypt 证书续期失败:%s", result.get("error")
                    )
                elif result and result.get("status") == "missing":
                    self.logger.warning(
                        "Let's Encrypt 已配置但找不到当前证书,请在设置 → 安全重新申请"
                    )
                elif result and result.get("status") == "renewed":
                    self.logger.info("Let's Encrypt 证书续期成功,准备重启加载新证书")
                    control = app["runtime_control"]
                    if not control["restart_requested"]:
                        control["restart_requested"] = True
                        control["restart_task"] = asyncio.create_task(
                            self.restart_after_certificate_renewal()
                        )
            except asyncio.CancelledError:
                raise
            except Exception:
                self.logger.exception("Let's Encrypt 证书续期检查失败")
            await asyncio.sleep(15 * 60)

    @staticmethod
    async def restart_after_certificate_renewal() -> None:
        await asyncio.sleep(0.5)
        signal.raise_signal(signal.SIGINT)

    async def on_cleanup(self, app: web.Application) -> None:
        activation_task = app.get("_data_activation_task")
        if activation_task:
            activation_task.cancel()
            try:
                await activation_task
            except asyncio.CancelledError:
                pass
        api = app.get("api")
        if api is not None and hasattr(api, "close_assistant_agent"):
            await api.close_assistant_agent()
        if api is not None and hasattr(api, "close_hosted_combat"):
            await api.close_hosted_combat()
        plugin_host = app.get("plugin_host")
        if plugin_host:
            await plugin_host.cleanup()
        hub_client = app.get("hub_client")
        if hub_client:
            await hub_client.close()
        embed_task = app.get("_embedding_backfill_task")
        if embed_task:
            embed_task.cancel()
            try:
                await embed_task
            except asyncio.CancelledError:
                pass
        save_task = app.get("_save_task")
        if save_task:
            save_task.cancel()
        renewal_task = app.get("_certificate_renewal_task")
        if renewal_task:
            renewal_task.cancel()
            try:
                await renewal_task
            except asyncio.CancelledError:
                pass
        subsystems: TRPGSubsystems | None = app.get("subsystems")
        if not subsystems:
            return
        try:
            await subsystems.registry.save_all_active()
        except Exception:
            self.logger.exception("关闭前保存失败")
        await close_model_clients(subsystems)
        if subsystems.memory_store and subsystems.memory_store.embedding_client:
            await subsystems.memory_store.embedding_client.close()
        subsystems.lorebook_store.close()
        subsystems.memory_store.close()