feat(update): add interactive channel upgrade flow

This commit is contained in:
jxxghp
2026-09-05 21:35:30 +08:00
parent 1fb4a45064
commit e28f4a11cd
17 changed files with 1724 additions and 86 deletions
+2
View File
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
SubscriptionMutationScope, SubscriptionMutationScope,
SyncSubscriptionMutationScope, SyncSubscriptionMutationScope,
) )
from app.application.system import SystemService
from app.application.transfer.execution import TransferExecutionRepository from app.application.transfer.execution import TransferExecutionRepository
from app.application.transfer.workflow import TransferAdmissionRepository from app.application.transfer.workflow import TransferAdmissionRepository
@@ -84,6 +85,7 @@ class ChainRuntimeContext:
default_factory=lambda: ChainRuntimeConfig(media_extensions=()) default_factory=lambda: ChainRuntimeConfig(media_extensions=())
) )
stop_state: StopState = field(default_factory=lambda: runtime_stop_state) stop_state: StopState = field(default_factory=lambda: runtime_stop_state)
system_service: Optional[SystemService] = None
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext: def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
+17 -3
View File
@@ -34,7 +34,7 @@ class SlashInteractionManager:
_ttl = timedelta(hours=24) _ttl = timedelta(hours=24)
def __init__(self): def __init__(self) -> None:
"""初始化按请求和用户索引的 slash 会话表。""" """初始化按请求和用户索引的 slash 会话表。"""
self._by_id: Dict[str, PendingSlashInteraction] = {} self._by_id: Dict[str, PendingSlashInteraction] = {}
self._by_user: Dict[str, str] = {} self._by_user: Dict[str, str] = {}
@@ -140,9 +140,23 @@ class InteractionDispatch:
class MessageGateway(Protocol): class MessageGateway(Protocol):
"""声明交互控制器使用的消息发送和编辑能力。""" """声明交互控制器使用的消息发送和编辑能力。"""
def post_message(self, message: Message): ... def post_message(self, message: Message) -> None:
"""发送一条交互消息。"""
...
def edit_message(self, **kwargs) -> bool: ... def edit_message(
self,
channel: NotificationChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
buttons: Optional[List[List[Dict[str, Any]]]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""编辑一条已发送的交互消息。"""
...
def supports_interaction_buttons(channel: Optional[NotificationChannel]) -> bool: def supports_interaction_buttons(channel: Optional[NotificationChannel]) -> bool:
+3 -1
View File
@@ -1,7 +1,7 @@
""" """
交互路由层:统一选择活动文本会话,并按固定顺序派发按钮回调。 交互路由层:统一选择活动文本会话,并按固定顺序派发按钮回调。
文本会话候选覆盖 Site、Subscribe、Skill、Media类, 文本会话候选覆盖 Site、Subscribe、Skill、Media、Update 五类,
按会话创建时间选择最近激活的一条,避免旧会话抢占新会话的输入。 按会话创建时间选择最近激活的一条,避免旧会话抢占新会话的输入。
""" """
@@ -16,6 +16,7 @@ from app.application.messaging.media import media_interaction_manager
from app.application.messaging.site import site_interaction_manager from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import skill_interaction_manager from app.application.messaging.skill import skill_interaction_manager
from app.application.messaging.subscribe import subscribe_interaction_manager from app.application.messaging.subscribe import subscribe_interaction_manager
from app.application.messaging.update import update_interaction_manager
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -117,5 +118,6 @@ def has_pending_interaction(user_id: Union[str, int]) -> bool:
subscribe_interaction_manager, subscribe_interaction_manager,
skill_interaction_manager, skill_interaction_manager,
media_interaction_manager, media_interaction_manager,
update_interaction_manager,
) )
) )
+999
View File
@@ -0,0 +1,999 @@
"""通知渠道中的主程序更新检查、下载进度和重启确认交互。"""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from threading import Lock
from typing import Any, Optional, Protocol, Union
from app.application.messaging.interaction import (
MessageGateway,
PendingSlashInteraction,
SlashInteractionManager,
supports_interaction_buttons,
update_or_post_message,
)
from app.application.system import SystemOperationResult
from app.runtime.log import logger
from app.schemas.message import Message
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.system import SystemUpdateItemStatus, SystemUpdateStatus, SystemUpdateType
from app.schemas.types import NotificationChannel
update_interaction_manager = SlashInteractionManager()
_monitor_lock = Lock()
_monitored_requests: set[str] = set()
class SystemUpdateInteractionActions(Protocol):
"""声明更新交互调用的主程序升级应用用例。"""
def update_status(self) -> SystemUpdateStatus:
"""读取当前后台更新状态。"""
...
def check_update(self) -> SystemUpdateStatus:
"""立即检查主程序正式版本更新。"""
...
def download_update(
self,
target: SystemUpdateType = "application",
) -> SystemOperationResult:
"""启动主程序更新包下载。"""
...
def install_update(
self,
target: SystemUpdateType = "application",
) -> SystemOperationResult:
"""确认主程序更新包并请求重启安装。"""
...
UpdateMonitorSubmitter = Callable[[Coroutine[Any, Any, None]], Any]
RestartMarker = Callable[[NotificationChannel, Union[str, int], Optional[str]], None]
RestartMarkerClearer = Callable[[], None]
@dataclass(frozen=True, slots=True)
class SystemUpdateInteractionView:
"""保存一次渠道无关的更新交互展示内容。"""
title: str
text: str
buttons: Optional[list[list[dict[str, str]]]] = None
class SystemUpdateInteractionHandler:
"""编排 `/update` 的检查、下载进度编辑和重启确认流程。"""
_poll_interval_seconds = 3.0
_terminal_download_states = {"idle", "available", "ready", "failed", "installing"}
def __init__(
self,
*,
messenger: MessageGateway,
actions: SystemUpdateInteractionActions,
submit_monitor: UpdateMonitorSubmitter,
mark_restart: RestartMarker,
clear_restart_marker: RestartMarkerClearer,
poll_interval_seconds: float = _poll_interval_seconds,
) -> None:
"""注入消息网关、系统更新用例和受管后台任务提交器。"""
self._messenger = messenger
self._actions = actions
self._submit_monitor = submit_monitor
self._mark_restart = mark_restart
self._clear_restart_marker = clear_restart_marker
self._poll_interval_seconds = max(0.0, poll_interval_seconds)
def remote_update(
self,
arg_str: str = "",
channel: Optional[NotificationChannel] = None,
userid: Optional[Union[str, int]] = None,
source: Optional[str] = None,
) -> None:
"""执行 `/update`,检查正式版本并创建后续确认会话。"""
if channel is None or userid is None:
return
request = update_interaction_manager.create_or_replace(
user_id=userid,
command="/update",
channel=channel,
source=source,
username=None,
)
try:
status = self._actions.check_update()
except Exception as error: # noqa: BLE001 交互入口必须回显稳定错误
logger.warning(f"检查 MoviePilot 更新失败:{error}")
self._render_check_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username="",
error=str(error),
)
return
normalized_arg = str(arg_str or "").strip()
if normalized_arg and self.handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username="",
text=normalized_arg,
):
return
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username="",
)
@staticmethod
def parse_callback(callback_data: str) -> Optional[tuple[str, str]]:
"""解析 `/update` 的按钮回调。"""
if not str(callback_data or "").startswith("update:"):
return None
parts = str(callback_data).split(":")
if len(parts) != 3 or not parts[1] or not parts[2]:
return None
return parts[1], parts[2]
def handle_callback_interaction(
self,
callback_data: str,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""消费 `/update` 按钮回调并保持原消息作为进度锚点。"""
parsed = self.parse_callback(callback_data)
if not parsed:
return False
request_id, action = parsed
request = update_interaction_manager.get_by_id(request_id, userid)
if request is None:
self._messenger.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="升级交互已失效,请重新发送 /update",
save_history=False,
)
)
return True
request.channel = channel
request.source = source
request.username = username
if action == "close":
update_interaction_manager.remove(request.request_id)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="MoviePilot 更新",
text="本次升级交互已结束,已开始的后台下载不会被取消。",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "refresh":
self._check_and_render(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "download":
self._start_download(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "install":
self._install_and_restart(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
return False
def handle_text_interaction(
self,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
text: str,
) -> bool:
"""消费不支持按钮渠道或用户主动输入的升级确认文本。"""
request = update_interaction_manager.get_by_user(userid)
if request is None:
return False
request.channel = channel
request.source = source
request.username = username
normalized = str(text or "").strip().lower()
if normalized in {"稍后", "取消", "关闭", "退出", "cancel", "close", "quit", "exit"}:
update_interaction_manager.remove(request.request_id)
self._messenger.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="本次升级交互已结束",
save_history=False,
)
)
return True
if normalized in {"刷新", "检查", "状态", "refresh", "check", "status"}:
self._check_and_render(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if normalized == "确认":
normalized = "确认重启" if request.awaiting_input == "install" else "确认升级"
if normalized in {"确认升级", "升级", "下载", "重试", "update", "download", "retry"}:
self._start_download(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if normalized in {"确认重启", "重启", "安装", "restart", "install"}:
self._install_and_restart(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
try:
status = self._actions.update_status()
except Exception as error: # noqa: BLE001 文本交互错误必须回显
self._render_check_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
)
return True
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
def _check_and_render(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""重新检查版本并在原交互消息中展示结果。"""
try:
status = self._actions.check_update()
except Exception as error: # noqa: BLE001 交互入口必须回显稳定错误
logger.warning(f"检查 MoviePilot 更新失败:{error}")
self._render_check_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _start_download(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""启动主程序下载,立即展示状态并登记持续进度监视。"""
try:
result = self._actions.download_update("application")
status = result.data if isinstance(result.data, SystemUpdateStatus) else self._actions.update_status()
except Exception as error: # noqa: BLE001 交互入口必须回显稳定错误
logger.warning(f"启动 MoviePilot 更新下载失败:{error}")
self._render_operation_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
operation_error = None if result.success else result.message or "无法启动更新包下载"
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
operation_error=operation_error,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
item = self._application_item(status)
if result.success and item.state == "downloading":
self._schedule_monitor(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
initial_item=item,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _install_and_restart(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""先更新提示并记录重启目标,再确认安装和请求受管重启。"""
try:
status = self._actions.update_status()
except Exception as error: # noqa: BLE001 交互入口必须回显稳定错误
self._render_operation_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
item = self._application_item(status)
if item.state != "ready":
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
operation_error="更新包尚未下载完成",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
installing = item.model_copy(update={"state": "installing", "can_install": False})
self._render_item(
request=request,
item=installing,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
self._mark_restart(channel, userid, source)
try:
result = self._actions.install_update("application")
except Exception as error: # noqa: BLE001 重启失败必须恢复交互
self._clear_restart_marker()
logger.warning(f"安装 MoviePilot 更新失败:{error}")
self._render_operation_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
if result.success:
update_interaction_manager.remove(request.request_id)
return
self._clear_restart_marker()
try:
status = self._actions.update_status()
except Exception: # noqa: BLE001 优先保留安装用例的稳定错误
status = SystemUpdateStatus(
state="ready",
current_version=item.current_version or "unknown",
updates=[item],
)
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
operation_error=result.message or "无法重启并安装更新",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _schedule_monitor(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
initial_item: SystemUpdateItemStatus,
original_message_id: Optional[Union[str, int]],
original_chat_id: Optional[str],
) -> None:
"""确保同一交互只登记一个非阻塞下载进度监视任务。"""
with _monitor_lock:
if request.request_id in _monitored_requests:
return
_monitored_requests.add(request.request_id)
monitor = self._monitor_download(
request_id=request.request_id,
channel=channel,
source=source,
userid=userid,
username=username,
initial_item=initial_item,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
try:
self._submit_monitor(monitor)
except Exception as error: # noqa: BLE001 下载继续运行,交互降级为手动刷新
monitor.close()
with _monitor_lock:
_monitored_requests.discard(request.request_id)
logger.warning(f"登记 MoviePilot 更新进度监视失败:{error}")
self._messenger.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="更新包已开始下载",
text="自动进度更新暂不可用,可重新发送 /update 查看状态。",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
save_history=False,
)
)
async def _monitor_download(
self,
*,
request_id: str,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
initial_item: SystemUpdateItemStatus,
original_message_id: Optional[Union[str, int]],
original_chat_id: Optional[str],
) -> None:
"""按 Web 端三秒节奏轮询状态,并持续编辑原消息直到下载终态。"""
last_fingerprint = self._item_fingerprint(initial_item)
last_progress_bucket = initial_item.progress // 10
edit_fallback_sent = False
try:
while True:
await asyncio.sleep(self._poll_interval_seconds)
request = update_interaction_manager.get_by_id(request_id, userid)
if request is None:
return
status = await asyncio.to_thread(self._actions.update_status)
item = self._application_item(status)
fingerprint = self._item_fingerprint(item)
if fingerprint != last_fingerprint:
request.awaiting_input = self._awaiting_input(item)
view = self._build_view(request=request, item=item, channel=channel)
if original_message_id and original_chat_id and ChannelCapabilityManager.supports_editing(channel):
edited = await asyncio.to_thread(
self._edit_view,
view=view,
channel=channel,
source=source,
userid=userid,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
if not edited and (not edit_fallback_sent or item.state in self._terminal_download_states):
await asyncio.to_thread(
self._post_view,
view=view,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
edit_fallback_sent = True
else:
progress_bucket = item.progress // 10
if progress_bucket != last_progress_bucket or item.state in self._terminal_download_states:
await asyncio.to_thread(
self._post_view,
view=view,
channel=channel,
source=source,
userid=userid,
username=username,
)
last_progress_bucket = progress_bucket
last_fingerprint = fingerprint
if item.state != "downloading":
if item.state == "idle":
update_interaction_manager.remove(request_id)
return
except Exception as error: # noqa: BLE001 监视错误不能影响实际下载
logger.warning(f"监视 MoviePilot 更新下载进度失败:{error}")
request = update_interaction_manager.get_by_id(request_id, userid)
if request is not None:
await asyncio.to_thread(
self._render_operation_failure,
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=f"读取下载进度失败:{error}",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
finally:
with _monitor_lock:
_monitored_requests.discard(request_id)
def _render_status(
self,
*,
request: PendingSlashInteraction,
status: SystemUpdateStatus,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
operation_error: Optional[str] = None,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""从聚合更新快照提取主程序状态并更新交互消息。"""
item = self._application_item(status)
self._render_item(
request=request,
item=item,
channel=channel,
source=source,
userid=userid,
username=username,
operation_error=operation_error,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
if item.state == "idle" and not item.error and not operation_error:
update_interaction_manager.remove(request.request_id)
def _render_item(
self,
*,
request: PendingSlashInteraction,
item: SystemUpdateItemStatus,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
operation_error: Optional[str] = None,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""更新会话阶段并优先编辑原消息展示指定主程序状态。"""
request.awaiting_input = self._awaiting_input(item)
view = self._build_view(
request=request,
item=item,
channel=channel,
operation_error=operation_error,
)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title=view.title,
text=view.text,
buttons=view.buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _render_check_failure(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
error: str,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""展示版本检查失败并保留刷新入口。"""
request.awaiting_input = "refresh"
buttons = self._buttons(
request=request,
channel=channel,
actions=(("重新检查", "refresh"), ("关闭", "close")),
)
text = f"{error or '无法读取更新状态'}\n\n"
if not buttons:
text += "回复“刷新”重试,回复“关闭”结束。"
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="检查 MoviePilot 更新失败",
text=text.strip(),
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _render_operation_failure(
self,
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
error: str,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""读取最新状态后展示下载或安装动作失败。"""
try:
status = self._actions.update_status()
except Exception: # noqa: BLE001 保留原始动作错误
self._render_check_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=error,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return
self._render_status(
request=request,
status=status,
channel=channel,
source=source,
userid=userid,
username=username,
operation_error=error,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _build_view(
self,
*,
request: PendingSlashInteraction,
item: SystemUpdateItemStatus,
channel: NotificationChannel,
operation_error: Optional[str] = None,
) -> SystemUpdateInteractionView:
"""把主程序更新状态转换为与 Web 流程一致的渠道展示。"""
actions: tuple[tuple[str, str], ...]
if item.state == "available":
title = "发现 MoviePilot 主程序更新"
lines = [f"当前版本:{item.current_version or '未知'}", f"目标版本:{item.version or '未知'}"]
if item.frontend_version:
lines.append(f"配套前端:{item.frontend_version}")
if item.release_name and item.release_name != item.version:
lines.append(f"发布名称:{item.release_name}")
if item.published_at:
lines.append(f"发布时间:{item.published_at}")
notes = self._release_notes(item.release_notes)
if notes:
lines.extend(("", "更新说明:", notes))
actions = (("确认升级", "download"), ("稍后", "close"))
fallback = "回复“确认升级”开始下载,回复“稍后”关闭本次交互。"
elif item.state == "downloading":
title = "正在下载 MoviePilot 更新"
lines = [
f"目标版本:{item.version or '未知'}",
self._progress_line(item),
"下载完成后会继续提示确认重启。",
]
actions = ()
fallback = ""
elif item.state == "ready":
title = "MoviePilot 更新包已准备完成"
lines = [
f"目标版本:{item.version or '未知'}",
"更新包已下载并校验完成。确认后系统将重启并安装更新。",
]
if item.frontend_version:
lines.insert(1, f"配套前端:{item.frontend_version}")
actions = (("确认重启", "install"), ("稍后重启", "close"))
fallback = "回复“确认重启”开始安装,回复“稍后”关闭本次交互。"
elif item.state == "installing":
title = "正在重启并安装 MoviePilot 更新"
lines = ["重启请求已提交,请等待服务恢复。"]
actions = ()
fallback = ""
elif item.state == "failed":
title = "MoviePilot 更新下载失败"
lines = [item.error or "更新包下载失败,请重试。"]
actions = (("重试", "download"), ("关闭", "close"))
fallback = "回复“重试”重新下载,回复“关闭”结束。"
elif item.error:
title = "检查 MoviePilot 更新失败"
lines = [item.error]
actions = (("重新检查", "refresh"), ("关闭", "close"))
fallback = "回复“刷新”重试,回复“关闭”结束。"
else:
title = "MoviePilot 已是最新版本"
lines = [f"当前版本:{item.current_version or '未知'}"]
actions = ()
fallback = ""
if operation_error:
title = "MoviePilot 升级操作失败"
lines = [operation_error, "", *lines]
buttons = self._buttons(request=request, channel=channel, actions=actions)
if fallback and not buttons:
lines.extend(("", fallback))
return SystemUpdateInteractionView(
title=title,
text="\n".join(lines).strip(),
buttons=buttons,
)
@staticmethod
def _application_item(status: SystemUpdateStatus) -> SystemUpdateItemStatus:
"""读取主程序明细,并兼容旧版只有聚合字段的状态。"""
item = next((value for value in status.updates if value.type == "application"), None)
if item is not None:
return item
return SystemUpdateItemStatus(
type="application",
state=status.state,
current_version=status.current_version,
version=status.version,
frontend_version=status.frontend_version,
release_name=status.release_name,
release_notes=status.release_notes,
published_at=status.published_at,
checked_at=status.checked_at,
downloaded_bytes=status.downloaded_bytes,
total_bytes=status.total_bytes,
progress=status.progress,
error=status.error,
can_update=status.can_update,
can_install=status.can_install,
)
@staticmethod
def _awaiting_input(item: SystemUpdateItemStatus) -> Optional[str]:
"""把更新状态映射为文本渠道下一步输入阶段。"""
if item.state in {"available", "failed"}:
return "download"
if item.state == "ready":
return "install"
if item.error:
return "refresh"
return None
@staticmethod
def _item_fingerprint(item: SystemUpdateItemStatus) -> tuple[Any, ...]:
"""生成需要刷新消息的状态指纹。"""
return (
item.state,
item.version,
item.frontend_version,
item.downloaded_bytes,
item.total_bytes,
item.progress,
item.error,
item.can_update,
item.can_install,
)
@staticmethod
def _release_notes(notes: Optional[str], limit: int = 1200) -> str:
"""限制发布说明长度,避免超过通知渠道单条消息上限。"""
normalized = str(notes or "").strip()
if len(normalized) <= limit:
return normalized
return f"{normalized[:limit].rstrip()}..."
@staticmethod
def _format_bytes(value: int) -> str:
"""使用与 Web 端相同的 MB 展示下载量。"""
megabytes = max(0, int(value or 0)) / 1024 / 1024
return f"{megabytes:.0f} MB" if megabytes >= 100 else f"{megabytes:.1f} MB"
@classmethod
def _progress_line(cls, item: SystemUpdateItemStatus) -> str:
"""构造宽度稳定的文本进度条和下载量。"""
progress = min(100, max(0, int(item.progress or 0)))
filled = min(10, progress // 10)
progress_bar = f"[{'=' * filled}{'.' * (10 - filled)}] {progress}%"
downloaded = cls._format_bytes(item.downloaded_bytes)
if item.total_bytes > 0:
return f"{progress_bar}\n{downloaded} / {cls._format_bytes(item.total_bytes)}"
return f"{progress_bar}\n已下载 {downloaded}"
@staticmethod
def _buttons(
*,
request: PendingSlashInteraction,
channel: NotificationChannel,
actions: tuple[tuple[str, str], ...],
) -> Optional[list[list[dict[str, str]]]]:
"""为支持按钮回调的渠道构造单行操作按钮。"""
if not actions or not supports_interaction_buttons(channel):
return None
return [
[
{
"text": text,
"callback_data": f"update:{request.request_id}:{action}",
}
for text, action in actions
]
]
def _edit_view(
self,
*,
view: SystemUpdateInteractionView,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
original_message_id: Union[str, int],
original_chat_id: str,
) -> bool:
"""直接编辑进度锚点,避免轮询阶段重复发送消息。"""
if not source:
return False
metadata = {"userid": userid} if channel == NotificationChannel.WebAgent else None
return bool(
self._messenger.edit_message(
channel=channel,
source=source,
message_id=original_message_id,
chat_id=original_chat_id,
title=view.title,
text=view.text,
buttons=view.buttons,
metadata=metadata,
)
)
def _post_view(
self,
*,
view: SystemUpdateInteractionView,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""在无法编辑时发送一次进度或终态消息。"""
self._messenger.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title=view.title,
text=view.text,
buttons=view.buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
save_history=False,
)
)
+1
View File
@@ -74,6 +74,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
self.classification_service = context.classification_service self.classification_service = context.classification_service
self.runtime_config = context.configuration self.runtime_config = context.configuration
self.stop_state = context.stop_state self.stop_state = context.stop_state
self.system_service = context.system_service
self.durable_event_writer = context.durable_event_writer self.durable_event_writer = context.durable_event_writer
self._module_dispatcher = context.module_dispatcher_factory( self._module_dispatcher = context.module_dispatcher_factory(
module_catalog=self.modulemanager, module_catalog=self.modulemanager,
+19 -3
View File
@@ -9,7 +9,7 @@ from concurrent.futures import CancelledError as FutureCancelledError
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Protocol, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from app.application.agent import ( from app.application.agent import (
@@ -27,10 +27,12 @@ from app.application.messaging.session import MessageSessionService
from app.application.messaging.site import site_interaction_manager from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager
from app.application.messaging.subscribe import subscribe_interaction_manager from app.application.messaging.subscribe import subscribe_interaction_manager
from app.application.messaging.update import update_interaction_manager
from app.chain.base import ChainBase from app.chain.base import ChainBase
from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain
from app.chain.site import SiteChain from app.chain.site import SiteChain
from app.chain.subscribe.facade import SubscribeChain from app.chain.subscribe.facade import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer.facade import TransferChain from app.chain.transfer.facade import TransferChain
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.loop import main_loop_registry from app.runtime.loop import main_loop_registry
@@ -705,7 +707,9 @@ class MessageChain(ChainBase):
def _interaction_router(self) -> InteractionRouter: def _interaction_router(self) -> InteractionRouter:
"""构造交互路由器,文本会话按创建时间选择,回调路由注册顺序即优先级。""" """构造交互路由器,文本会话按创建时间选择,回调路由注册顺序即优先级。"""
def session_text(handle): def session_text(
handle: Callable[..., Any],
) -> Callable[[InteractionContext, str], bool]:
"""包装传统交互入口为会话路由的文本处理函数,保持懒构造。""" """包装传统交互入口为会话路由的文本处理函数,保持懒构造。"""
def _handle(context: InteractionContext, text: str) -> bool: def _handle(context: InteractionContext, text: str) -> bool:
return bool(handle( return bool(handle(
@@ -717,7 +721,9 @@ class MessageChain(ChainBase):
)) ))
return _handle return _handle
def callback_dispatch(handle): def callback_dispatch(
handle: Callable[..., Any],
) -> Callable[[str, InteractionContext], InteractionDispatch]:
"""包装传统回调入口为回调路由的派发函数,保持懒构造。""" """包装传统回调入口为回调路由的派发函数,保持懒构造。"""
def _dispatch(callback_data: str, context: InteractionContext) -> InteractionDispatch: def _dispatch(callback_data: str, context: InteractionContext) -> InteractionDispatch:
return InteractionDispatch(handled=bool(handle( return InteractionDispatch(handled=bool(handle(
@@ -754,6 +760,11 @@ class MessageChain(ChainBase):
get_pending=media_interaction_manager.get_by_user, get_pending=media_interaction_manager.get_by_user,
handle_text=session_text(lambda **kw: _MediaInteractionChain().handle_text_interaction(**kw)), handle_text=session_text(lambda **kw: _MediaInteractionChain().handle_text_interaction(**kw)),
), ),
SessionRoute(
name="update",
get_pending=update_interaction_manager.get_by_user,
handle_text=session_text(lambda **kw: SystemChain().handle_update_text_interaction(**kw)),
),
] ]
def _dispatch_agent_choice(callback_data: str, context: InteractionContext) -> InteractionDispatch: def _dispatch_agent_choice(callback_data: str, context: InteractionContext) -> InteractionDispatch:
@@ -820,6 +831,11 @@ class MessageChain(ChainBase):
matches=lambda data: _MediaInteractionChain.parse_callback(data) is not None, matches=lambda data: _MediaInteractionChain.parse_callback(data) is not None,
dispatch=callback_dispatch(lambda **kw: _MediaInteractionChain().handle_callback_interaction(**kw)), dispatch=callback_dispatch(lambda **kw: _MediaInteractionChain().handle_callback_interaction(**kw)),
), ),
CallbackRoute(
name="update",
matches=lambda data: data.startswith("update:"),
dispatch=callback_dispatch(lambda **kw: SystemChain().handle_update_callback_interaction(**kw)),
),
CallbackRoute( CallbackRoute(
name="agent_choice", name="agent_choice",
matches=lambda data: parse_agent_choice_callback(data) is not None, matches=lambda data: parse_agent_choice_callback(data) is not None,
+124 -1
View File
@@ -4,15 +4,18 @@ import re
import shutil import shutil
import threading import threading
import uuid import uuid
from collections.abc import Mapping from collections.abc import Coroutine, Mapping
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Protocol, Union from typing import Any, Optional, Protocol, Union
from app.application.configuration import get_chain_runtime_config_snapshot from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.messaging.update import SystemUpdateInteractionHandler
from app.chain.base import ChainBase from app.chain.base import ChainBase
from app.runtime import version as runtime_version from app.runtime import version as runtime_version
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.loop import main_loop_registry
from app.runtime.state import SystemHelper from app.runtime.state import SystemHelper
from app.runtime.tasks import get_task_registry
from app.schemas.message import Message from app.schemas.message import Message
from app.schemas.notification import NotificationChannel from app.schemas.notification import NotificationChannel
@@ -105,8 +108,99 @@ class SystemChain(ChainBase):
""" """
_restart_file = "__system_restart__" _restart_file = "__system_restart__"
_update_restart_file = "__system_update_restart__"
_plugin_restore_pending_file = "__plugin_restore_pending__" _plugin_restore_pending_file = "__plugin_restore_pending__"
def _update_interaction_handler(self) -> SystemUpdateInteractionHandler:
"""构造复用当前消息网关和系统应用服务的更新交互控制器。"""
if self.system_service is None:
raise RuntimeError("系统更新服务尚未由启动组合根装配")
return SystemUpdateInteractionHandler(
messenger=self,
actions=self.system_service,
submit_monitor=self._submit_update_monitor,
mark_restart=self._mark_update_restart,
clear_restart_marker=self._clear_update_restart_marker,
)
@staticmethod
def _submit_update_monitor(monitor: Coroutine[Any, Any, None]) -> None:
"""把进度监视协程跨线程登记到宿主事件循环。"""
get_task_registry().submit_threadsafe(
monitor,
loop=main_loop_registry.require(),
owner="chain.system.update_progress",
)
def _mark_update_restart(
self,
channel: NotificationChannel,
userid: Union[int, str],
source: Optional[str],
) -> None:
"""记录升级重启的回复目标,供服务恢复后发送完成通知。"""
self.save_cache(
{"channel": channel.value, "userid": userid, "source": source},
self._update_restart_file,
)
def _clear_update_restart_marker(self) -> None:
"""升级安装未能进入重启阶段时删除完成通知标记。"""
self.remove_cache(self._update_restart_file)
def remote_update(
self,
arg_str: str = "",
channel: Optional[NotificationChannel] = None,
userid: Optional[Union[int, str]] = None,
source: Optional[str] = None,
) -> None:
"""检查正式版本并启动通知渠道中的升级确认交互。"""
self._update_interaction_handler().remote_update(
arg_str=arg_str,
channel=channel,
userid=userid,
source=source,
)
def handle_update_callback_interaction(
self,
callback_data: str,
channel: NotificationChannel,
source: Optional[str],
userid: Union[int, str],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""处理更新交互按钮并把原消息定位参数交给进度编辑器。"""
return self._update_interaction_handler().handle_callback_interaction(
callback_data=callback_data,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def handle_update_text_interaction(
self,
channel: NotificationChannel,
source: Optional[str],
userid: Union[int, str],
username: Optional[str],
text: str,
) -> bool:
"""处理不支持按钮渠道中的升级或重启确认文本。"""
return self._update_interaction_handler().handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
)
def remote_clear_cache(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None): def remote_clear_cache(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None):
""" """
清理系统缓存 清理系统缓存
@@ -445,6 +539,35 @@ class SystemChain(ChainBase):
save_history=False)) save_history=False))
self.remove_cache(self._restart_file) self.remove_cache(self._restart_file)
update_restart_channel = self.load_cache(self._update_restart_file)
if update_restart_channel:
if not isinstance(update_restart_channel, dict):
update_restart_channel = json.loads(update_restart_channel)
channel = next(
(
candidate
for candidate in NotificationChannel.__members__.values()
if candidate.value == update_restart_channel.get("channel")
),
None,
)
userid = update_restart_channel.get("userid")
source = update_restart_channel.get("source")
self.post_message(
Message(
channel=channel,
source=source,
title=(
"MoviePilot 更新安装完成!\n"
f"当前后端版本:{runtime_version.get_app_version()}\n"
f"当前前端版本:{runtime_version.get_frontend_version()}"
),
userid=userid,
save_history=False,
)
)
self.remove_cache(self._update_restart_file)
@staticmethod @staticmethod
def __get_server_release_version(): def __get_server_release_version():
""" """
+6
View File
@@ -110,6 +110,12 @@ class Command(metaclass=Singleton):
"category": "管理", "category": "管理",
"data": {}, "data": {},
}, },
"/update": {
"func": SystemChain().remote_update,
"description": "检查更新",
"category": "管理",
"data": {},
},
"/version": { "/version": {
"func": SystemChain().version, "func": SystemChain().version,
"description": "当前版本", "description": "当前版本",
+68 -65
View File
@@ -1,21 +1,20 @@
import json import json
import re import re
from threading import Lock
from pathlib import Path from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from slack_bolt import App from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient from slack_sdk import WebClient
from app.runtime.settings import get_runtime_setting
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.adapters.network.http import RequestUtils from app.adapters.network.http import RequestUtils
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import Context, MediaInfo
from app.domain.metainfo import MetaInfo
from app.foundation import size as size_tools from app.foundation import size as size_tools
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
lock = Lock() lock = Lock()
@@ -303,75 +302,79 @@ class Slack:
# 消息广播 # 消息广播
channel = self.__find_public_channel() channel = self.__find_public_channel()
# 消息文本 # 消息文本
message_text = "" message_text = f"{title}\n{text or ''}"
# 结构体 # 结构体
blocks = [] blocks = []
if not image: if image:
message_text = f"{title}\n{text or ''}"
else:
# 消息图片 # 消息图片
if image: blocks.append({"type": "section", "text": {
# 拼装消息内容 "type": "mrkdwn",
blocks.append({"type": "section", "text": { "text": f"*{title}*\n{text or ''}"
"type": "mrkdwn", }, 'accessory': {
"text": f"*{title}*\n{text or ''}"
}, 'accessory': {
"type": "image", "type": "image",
"image_url": f"{image}", "image_url": f"{image}",
"alt_text": f"{title}" "alt_text": f"{title}"
}}) }})
# 自定义按钮 elif buttons or link:
if buttons: blocks.append({
for button_row in buttons: "type": "section",
elements = [] "text": {
for button in button_row: "type": "mrkdwn",
if "url" in button: "text": f"*{title}*\n{text or ''}",
# URL按钮 },
elements.append({ })
"type": "button", # 自定义按钮
"text": { if buttons:
"type": "plain_text", for button_row in buttons:
"text": button["text"], elements = []
"emoji": True for button in button_row:
}, if "url" in button:
"url": button["url"], # URL按钮
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" elements.append({
})
else:
# 回调按钮
elements.append({
"type": "button",
"text": {
"type": "plain_text",
"text": button["text"],
"emoji": True
},
"value": button["callback_data"],
"action_id": f"actionId-{button['callback_data']}"
})
if elements:
blocks.append({
"type": "actions",
"elements": elements
})
elif link:
# 默认链接按钮
blocks.append({
"type": "actions",
"elements": [
{
"type": "button", "type": "button",
"text": { "text": {
"type": "plain_text", "type": "plain_text",
"text": "查看详情", "text": button["text"],
"emoji": True "emoji": True
}, },
"value": "click_me_url", "url": button["url"],
"url": f"{link}", "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
"action_id": "actionId-url" })
} else:
] # 回调按钮
}) elements.append({
"type": "button",
"text": {
"type": "plain_text",
"text": button["text"],
"emoji": True
},
"value": button["callback_data"],
"action_id": f"actionId-{button['callback_data']}"
})
if elements:
blocks.append({
"type": "actions",
"elements": elements
})
elif link:
# 默认链接按钮
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "查看详情",
"emoji": True
},
"value": "click_me_url",
"url": f"{link}",
"action_id": "actionId-url"
}
]
})
# 判断是编辑消息还是发送新消息 # 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id: if original_message_id and original_chat_id:
+5
View File
@@ -23,6 +23,7 @@ from app.application.image import (
configure_wallpaper_providers, configure_wallpaper_providers,
reset_wallpaper_providers, reset_wallpaper_providers,
) )
from app.application.system import SystemService
from app.chain._recognition import ( from app.chain._recognition import (
RecognitionSharePort, RecognitionSharePort,
configure_recognition_share_port, configure_recognition_share_port,
@@ -85,6 +86,7 @@ def build_chain_runtime_context(
system_config: SystemConfigOper, system_config: SystemConfigOper,
configuration: Callable[[], ChainRuntimeConfig], configuration: Callable[[], ChainRuntimeConfig],
classification_service: ClassificationExecutionService, classification_service: ClassificationExecutionService,
system_service: SystemService,
) -> ChainRuntimeContext: ) -> ChainRuntimeContext:
"""创建 Chain 无参兼容入口共享的运行时对象与数据端口。""" """创建 Chain 无参兼容入口共享的运行时对象与数据端口。"""
return ChainRuntimeContext( return ChainRuntimeContext(
@@ -125,6 +127,7 @@ def build_chain_runtime_context(
configuration=configuration(), configuration=configuration(),
durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory),
stop_state=runtime_stop_state, stop_state=runtime_stop_state,
system_service=system_service,
) )
@@ -153,6 +156,7 @@ def configure_chain_runtime_context(
system_config: SystemConfigOper, system_config: SystemConfigOper,
configuration: Callable[[], ChainRuntimeConfig], configuration: Callable[[], ChainRuntimeConfig],
classification_service: ClassificationExecutionService, classification_service: ClassificationExecutionService,
system_service: SystemService,
) -> None: ) -> None:
"""登记按需构造的 Chain 上下文,保持无参 Chain 的插件兼容合同。""" """登记按需构造的 Chain 上下文,保持无参 Chain 的插件兼容合同。"""
configure_chain_runtime_context_provider( configure_chain_runtime_context_provider(
@@ -161,6 +165,7 @@ def configure_chain_runtime_context(
system_config=system_config, system_config=system_config,
configuration=configuration, configuration=configuration,
classification_service=classification_service, classification_service=classification_service,
system_service=system_service,
) )
) )
+1
View File
@@ -711,6 +711,7 @@ async def _initialize_modules() -> HostRuntime:
system_config=system_config, system_config=system_config,
configuration=configuration.runtime.chain, configuration=configuration.runtime.chain,
classification_service=classification.execution, classification_service=classification.execution,
system_service=host_runtime.system,
) )
# 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。 # 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。
configure_security_access() configure_security_access()
+1 -1
View File
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行,11 个超过 2,000 行 |
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
| 全量 mypy 历史债务 | 9,528 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | 全量 mypy 历史债务 | 9,508 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
| Ruff 历史诊断 | 547 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | Ruff 历史诊断 | 547 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | | 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
+1
View File
@@ -30,6 +30,7 @@ files =
app/application/chain/context.py, app/application/chain/context.py,
app/application/chain/events.py, app/application/chain/events.py,
app/application/messaging/ingress.py, app/application/messaging/ingress.py,
app/application/messaging/update.py,
app/application/subscription/delete.py, app/application/subscription/delete.py,
app/application/subscription/identity.py, app/application/subscription/identity.py,
app/application/subscription/mutation.py, app/application/subscription/mutation.py,
+5 -8
View File
@@ -679,7 +679,7 @@
"type-arg": 8 "type-arg": 8
}, },
"app/application/messaging/interaction.py": { "app/application/messaging/interaction.py": {
"no-untyped-def": 4, "no-untyped-def": 1,
"type-arg": 2 "type-arg": 2
}, },
"app/application/messaging/media.py": { "app/application/messaging/media.py": {
@@ -707,7 +707,6 @@
"app/application/messaging/site.py": { "app/application/messaging/site.py": {
"arg-type": 1, "arg-type": 1,
"assignment": 2, "assignment": 2,
"no-untyped-call": 1,
"no-untyped-def": 2 "no-untyped-def": 2
}, },
"app/application/messaging/skill.py": { "app/application/messaging/skill.py": {
@@ -718,7 +717,6 @@
"app/application/messaging/subscribe.py": { "app/application/messaging/subscribe.py": {
"arg-type": 1, "arg-type": 1,
"assignment": 2, "assignment": 2,
"no-untyped-call": 1,
"no-untyped-def": 2 "no-untyped-def": 2
}, },
"app/application/module.py": { "app/application/module.py": {
@@ -890,11 +888,10 @@
"type-arg": 1 "type-arg": 1
}, },
"app/chain/message.py": { "app/chain/message.py": {
"arg-type": 21, "arg-type": 18,
"assignment": 2, "assignment": 2,
"no-any-return": 11, "no-any-return": 11,
"no-untyped-call": 8, "no-untyped-def": 4,
"no-untyped-def": 6,
"type-arg": 2, "type-arg": 2,
"union-attr": 1, "union-attr": 1,
"var-annotated": 1 "var-annotated": 1
@@ -922,7 +919,7 @@
"var-annotated": 1 "var-annotated": 1
}, },
"app/chain/site.py": { "app/chain/site.py": {
"arg-type": 2, "arg-type": 1,
"assignment": 6, "assignment": 6,
"attr-defined": 1, "attr-defined": 1,
"misc": 3, "misc": 3,
@@ -1111,7 +1108,7 @@
"union-attr": 2 "union-attr": 2
}, },
"app/command.py": { "app/command.py": {
"arg-type": 3, "arg-type": 2,
"assignment": 5, "assignment": 5,
"misc": 4, "misc": 4,
"no-untyped-call": 4, "no-untyped-call": 4,
+3
View File
@@ -46,6 +46,7 @@ def _context() -> ChainRuntimeContext:
user_repository=Mock(), user_repository=Mock(),
configuration=ChainRuntimeConfig(media_extensions=(".mkv",)), configuration=ChainRuntimeConfig(media_extensions=(".mkv",)),
durable_event_writer=Mock(), durable_event_writer=Mock(),
system_service=Mock(),
) )
@@ -60,6 +61,7 @@ def test_chain_accepts_explicit_runtime_context() -> None:
assert chain.eventmanager is context.event_manager assert chain.eventmanager is context.event_manager
assert chain.messagehelper is context.message_helper assert chain.messagehelper is context.message_helper
assert chain.durable_event_writer is context.durable_event_writer assert chain.durable_event_writer is context.durable_event_writer
assert chain.system_service is context.system_service
context.message_queue.bind.assert_called_once_with(chain.run_module) context.message_queue.bind.assert_called_once_with(chain.run_module)
@@ -141,6 +143,7 @@ def test_chain_composition_registers_lazy_compatibility_provider(monkeypatch) ->
"system_config": Mock(), "system_config": Mock(),
"configuration": Mock(), "configuration": Mock(),
"classification_service": Mock(), "classification_service": Mock(),
"system_service": Mock(),
} }
monkeypatch.setattr(chain_composition, "build_chain_runtime_context", builder) monkeypatch.setattr(chain_composition, "build_chain_runtime_context", builder)
monkeypatch.setattr( monkeypatch.setattr(
+40 -4
View File
@@ -3,7 +3,7 @@ from unittest.mock import Mock, patch
from app.modules.slack import SlackModule from app.modules.slack import SlackModule
from app.modules.slack.slack import Slack from app.modules.slack.slack import Slack
from app.schemas import CommandRegisterEventData from app.schemas.event import CommandRegisterEventData
def test_slack_module_register_commands_filters_event_subset(): def test_slack_module_register_commands_filters_event_subset():
@@ -33,9 +33,7 @@ def test_slack_module_register_commands_filters_event_subset():
): ):
module.register_commands(original_commands) module.register_commands(original_commands)
client.register_commands.assert_called_once_with( client.register_commands.assert_called_once_with({"/sites": {"description": "管理站点"}})
{"/sites": {"description": "管理站点"}}
)
client.delete_commands.assert_not_called() client.delete_commands.assert_not_called()
@@ -103,3 +101,41 @@ def test_slack_normalizes_slash_command_names():
assert Slack._normalize_slack_command("CLEAR_CACHE") == "/clear_cache" assert Slack._normalize_slack_command("CLEAR_CACHE") == "/clear_cache"
assert Slack._normalize_slack_command("/中文") == "" assert Slack._normalize_slack_command("/中文") == ""
assert Slack._normalize_slack_command("/" + "a" * 32) == "" assert Slack._normalize_slack_command("/" + "a" * 32) == ""
def test_slack_plain_text_interaction_buttons_render_and_clear_on_edit():
"""纯文本交互必须显示按钮,后续无按钮编辑应清除旧操作区。"""
client = Slack.__new__(Slack)
client._client = Mock()
client._client.chat_postMessage.return_value = {"ok": True, "ts": "1", "channel": "C1"}
client._client.chat_update.return_value = {"ok": True, "ts": "1", "channel": "C1"}
buttons = [[{"text": "确认升级", "callback_data": "update:req:download"}]]
assert (
client.send_msg(
title="发现更新",
text="v3.0.0 -> v3.1.0",
userid="C1",
buttons=buttons,
)[0]
is True
)
posted = client._client.chat_postMessage.call_args.kwargs
assert posted["text"] == "发现更新\nv3.0.0 -> v3.1.0"
assert [block["type"] for block in posted["blocks"]] == ["section", "actions"]
assert posted["blocks"][1]["elements"][0]["value"] == "update:req:download"
assert (
client.send_msg(
title="正在下载",
text="50%",
original_message_id="1",
original_chat_id="C1",
)[0]
is True
)
updated = client._client.chat_update.call_args.kwargs
assert updated["text"] == "正在下载\n50%"
assert updated["blocks"] == []
+429
View File
@@ -0,0 +1,429 @@
"""通知渠道主程序升级交互的状态机、进度编辑和路由测试。"""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from typing import Any
from unittest.mock import Mock, patch
import pytest
from app.application.messaging import update as update_module
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.router import has_pending_interaction
from app.application.messaging.update import (
SystemUpdateInteractionHandler,
update_interaction_manager,
)
from app.application.system import SystemOperationResult
from app.chain.message import MessageChain
from app.chain.system import SystemChain
from app.schemas.message import Message
from app.schemas.system import SystemUpdateItemStatus, SystemUpdateStatus
from app.schemas.types import NotificationChannel
class _Messenger:
"""记录交互发送与编辑调用的内存消息网关。"""
def __init__(self, *, edit_success: bool = True) -> None:
"""初始化消息记录并配置编辑调用结果。"""
self.messages: list[Message] = []
self.edits: list[dict[str, Any]] = []
self.edit_success = edit_success
def post_message(self, message: Message) -> None:
"""记录一条新发送的消息。"""
self.messages.append(message)
def edit_message(self, **kwargs: Any) -> bool:
"""记录一次原消息编辑并返回可控结果。"""
self.edits.append(kwargs)
return self.edit_success
class _Actions:
"""提供可排队状态和结果的系统更新应用用例替身。"""
def __init__(self, check_status: SystemUpdateStatus) -> None:
"""使用初始检查状态构造更新用例替身。"""
self.check_status = check_status
self.current_status = check_status
self.monitor_statuses: list[SystemUpdateStatus] = []
self.download_result = SystemOperationResult(True, data=check_status)
self.install_result = SystemOperationResult(True, "restarting")
self.download_calls: list[str] = []
self.install_calls: list[str] = []
def check_update(self) -> SystemUpdateStatus:
"""返回配置的检查结果。"""
self.current_status = self.check_status
return self.check_status
def update_status(self) -> SystemUpdateStatus:
"""按顺序返回监视状态,耗尽后保留最后状态。"""
if self.monitor_statuses:
self.current_status = self.monitor_statuses.pop(0)
return self.current_status
def download_update(self, target: str = "application") -> SystemOperationResult:
"""记录下载目标并返回配置结果。"""
self.download_calls.append(target)
if isinstance(self.download_result.data, SystemUpdateStatus):
self.current_status = self.download_result.data
return self.download_result
def install_update(self, target: str = "application") -> SystemOperationResult:
"""记录安装目标并返回配置结果。"""
self.install_calls.append(target)
return self.install_result
def _status(
state: str,
*,
progress: int = 0,
downloaded_bytes: int = 0,
total_bytes: int = 0,
error: str | None = None,
) -> SystemUpdateStatus:
"""构造只包含主程序明细的聚合更新快照。"""
version = "v3.1.0" if state != "idle" or error else None
item = SystemUpdateItemStatus(
type="application",
state=state,
current_version="v3.0.0",
version=version,
frontend_version="v3.1.0" if version else None,
release_name="MoviePilot v3.1.0" if version else None,
release_notes="修复升级流程并更新前端资源" if version else None,
downloaded_bytes=downloaded_bytes,
total_bytes=total_bytes,
progress=progress,
error=error,
can_update=state in {"available", "failed"},
can_install=state == "ready",
)
return SystemUpdateStatus(
state=state,
current_version="v3.0.0",
version=version,
frontend_version=item.frontend_version,
downloaded_bytes=downloaded_bytes,
total_bytes=total_bytes,
progress=progress,
error=error,
can_update=item.can_update,
can_install=item.can_install,
updates=[item],
)
@pytest.fixture(autouse=True)
def _reset_update_interactions() -> None:
"""隔离全局更新会话和活动监视请求。"""
update_interaction_manager.clear()
with update_module._monitor_lock:
update_module._monitored_requests.clear()
yield
update_interaction_manager.clear()
with update_module._monitor_lock:
update_module._monitored_requests.clear()
def _handler(
messenger: _Messenger,
actions: _Actions,
submitted: list[Coroutine[Any, Any, None]],
*,
mark_restart: Mock | None = None,
clear_restart_marker: Mock | None = None,
) -> SystemUpdateInteractionHandler:
"""构造零等待且可观察后台协程的更新交互控制器。"""
return SystemUpdateInteractionHandler(
messenger=messenger,
actions=actions,
submit_monitor=submitted.append,
mark_restart=mark_restart or Mock(),
clear_restart_marker=clear_restart_marker or Mock(),
poll_interval_seconds=0,
)
def test_update_command_prompts_for_download_when_release_is_available() -> None:
"""检测到新版本后应显示版本信息和确认升级按钮。"""
messenger = _Messenger()
actions = _Actions(_status("available"))
submitted: list[Coroutine[Any, Any, None]] = []
handler = _handler(messenger, actions, submitted)
handler.remote_update(
channel=NotificationChannel.Telegram,
userid="10001",
source="telegram-main",
)
request = update_interaction_manager.get_by_user("10001")
assert request is not None
assert request.awaiting_input == "download"
assert messenger.messages[-1].title == "发现 MoviePilot 主程序更新"
assert "当前版本:v3.0.0" in messenger.messages[-1].text
assert messenger.messages[-1].buttons == [
[
{
"text": "确认升级",
"callback_data": f"update:{request.request_id}:download",
},
{"text": "稍后", "callback_data": f"update:{request.request_id}:close"},
]
]
assert submitted == []
def test_download_callback_edits_same_message_until_restart_confirmation() -> None:
"""下载进度应持续编辑回调原消息,完成后在同一消息显示重启按钮。"""
messenger = _Messenger()
actions = _Actions(_status("available"))
actions.download_result = SystemOperationResult(
True,
data=_status(
"downloading",
progress=10,
downloaded_bytes=10 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
),
)
actions.monitor_statuses = [
_status(
"downloading",
progress=45,
downloaded_bytes=45 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
),
_status(
"ready",
progress=100,
downloaded_bytes=100 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
),
]
submitted: list[Coroutine[Any, Any, None]] = []
handler = _handler(messenger, actions, submitted)
request = update_interaction_manager.create_or_replace(
user_id="10001",
command="/update",
channel=NotificationChannel.Telegram,
source="telegram-main",
username="tester",
)
handled = handler.handle_callback_interaction(
callback_data=f"update:{request.request_id}:download",
channel=NotificationChannel.Telegram,
source="telegram-main",
userid="10001",
username="tester",
original_message_id="message-1",
original_chat_id="chat-1",
)
assert handled is True
assert actions.download_calls == ["application"]
assert len(submitted) == 1
asyncio.run(submitted.pop())
assert [edit["message_id"] for edit in messenger.edits] == [
"message-1",
"message-1",
"message-1",
]
assert "[=.........] 10%" in messenger.edits[0]["text"]
assert "[====......] 45%" in messenger.edits[1]["text"]
assert messenger.edits[-1]["title"] == "MoviePilot 更新包已准备完成"
assert messenger.edits[-1]["buttons"][0][0]["text"] == "确认重启"
pending = update_interaction_manager.get_by_user("10001")
assert pending is not None
assert pending.awaiting_input == "install"
def test_text_confirmation_uses_milestone_messages_without_editing_support() -> None:
"""无编辑能力的渠道仍应通过文本确认并按进度里程碑继续流程。"""
messenger = _Messenger()
actions = _Actions(_status("available"))
actions.download_result = SystemOperationResult(
True,
data=_status("downloading", progress=0, total_bytes=100 * 1024 * 1024),
)
actions.monitor_statuses = [
_status(
"downloading",
progress=51,
downloaded_bytes=51 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
),
_status(
"ready",
progress=100,
downloaded_bytes=100 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
),
]
submitted: list[Coroutine[Any, Any, None]] = []
handler = _handler(messenger, actions, submitted)
handler.remote_update(
channel=NotificationChannel.Wechat,
userid="wx-user",
source="wechat-main",
)
assert "回复“确认升级”" in messenger.messages[-1].text
assert (
handler.handle_text_interaction(
channel=NotificationChannel.Wechat,
source="wechat-main",
userid="wx-user",
username="tester",
text="确认升级",
)
is True
)
asyncio.run(submitted.pop())
assert messenger.edits == []
assert any("51%" in str(message.text) for message in messenger.messages)
assert messenger.messages[-1].title == "MoviePilot 更新包已准备完成"
assert "回复“确认重启”" in messenger.messages[-1].text
@pytest.mark.parametrize("install_success", [True, False])
def test_restart_confirmation_marks_receipt_and_recovers_failed_install(
install_success: bool,
) -> None:
"""确认重启应先更新消息,成功结束会话,失败则清理回执并恢复按钮。"""
messenger = _Messenger()
ready = _status(
"ready",
progress=100,
downloaded_bytes=100 * 1024 * 1024,
total_bytes=100 * 1024 * 1024,
)
actions = _Actions(ready)
actions.current_status = ready
actions.install_result = SystemOperationResult(
install_success,
"restarting" if install_success else "restart failed",
)
mark_restart = Mock()
clear_restart_marker = Mock()
handler = _handler(
messenger,
actions,
[],
mark_restart=mark_restart,
clear_restart_marker=clear_restart_marker,
)
request = update_interaction_manager.create_or_replace(
user_id="10001",
command="/update",
channel=NotificationChannel.Telegram,
source="telegram-main",
username="tester",
)
assert (
handler.handle_callback_interaction(
callback_data=f"update:{request.request_id}:install",
channel=NotificationChannel.Telegram,
source="telegram-main",
userid="10001",
username="tester",
original_message_id="message-1",
original_chat_id="chat-1",
)
is True
)
assert messenger.edits[0]["title"] == "正在重启并安装 MoviePilot 更新"
mark_restart.assert_called_once_with(
NotificationChannel.Telegram,
"10001",
"telegram-main",
)
assert actions.install_calls == ["application"]
if install_success:
clear_restart_marker.assert_not_called()
assert update_interaction_manager.get_by_user("10001") is None
else:
clear_restart_marker.assert_called_once_with()
assert messenger.edits[-1]["title"] == "MoviePilot 升级操作失败"
assert messenger.edits[-1]["buttons"][0][0]["text"] == "确认重启"
assert update_interaction_manager.get_by_user("10001") is not None
def test_update_session_and_callback_are_registered_in_message_router(monkeypatch) -> None:
"""统一消息路由应识别更新会话文本和 update 回调前缀。"""
request = update_interaction_manager.create_or_replace(
user_id="10001",
command="/update",
channel=NotificationChannel.Telegram,
source="telegram-main",
username="tester",
)
assert has_pending_interaction("10001") is True
callback = Mock(return_value=True)
monkeypatch.setattr(
SystemChain,
"handle_update_callback_interaction",
callback,
)
context = InteractionContext(
channel=NotificationChannel.Telegram,
source="telegram-main",
user_id="10001",
username="tester",
original_message_id="message-1",
original_chat_id="chat-1",
)
result = (
MessageChain()
._interaction_router()
.dispatch_callback(
context,
f"update:{request.request_id}:refresh",
)
)
assert result.handled is True
callback.assert_called_once()
def test_restart_finish_reports_actual_versions_for_update_receipt() -> None:
"""升级重启完成通知应报告当前运行版本而非旧版远端查询。"""
chain = SystemChain()
with (
patch.object(
chain,
"load_cache",
side_effect=[
None,
{
"channel": NotificationChannel.Telegram.value,
"userid": "10001",
"source": "telegram-main",
},
],
),
patch.object(chain, "post_message") as post_message,
patch.object(chain, "remove_cache") as remove_cache,
patch("app.chain.system.runtime_version.get_app_version", return_value="v3.1.0"),
patch("app.chain.system.runtime_version.get_frontend_version", return_value="v3.1.0"),
):
chain.restart_finish()
message = post_message.call_args.args[0]
assert message.source == "telegram-main"
assert "当前后端版本:v3.1.0" in message.title
assert "当前前端版本:v3.1.0" in message.title
remove_cache.assert_called_once_with(chain._update_restart_file)