Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1074 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
通知渠道中的主程序更新检查、下载进度和重启确认交互。
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, 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:
"""
读取当前后台更新状态。
:return: 主程序及资源更新项的当前状态和进度
"""
...
def check_update(self) -> SystemUpdateStatus:
"""
立即检查主程序正式版本更新。
:return: 主程序及资源更新项的当前状态和进度
"""
...
def download_update(self, target: SystemUpdateType = "application") -> SystemOperationResult:
"""
启动主程序更新包下载。
:param target: 更新对象:application 为主程序,resources 为运行资源,默认主程序
:return: 操作是否受理及状态说明;后台进度由更新状态接口继续查询
"""
...
def install_update(self, target: SystemUpdateType = "application") -> SystemOperationResult:
"""
确认主程序更新包并请求重启安装。
:param target: 更新对象:application 为主程序,resources 为运行资源,默认主程序
:return: 操作是否受理及状态说明;后台进度由更新状态接口继续查询
"""
...
UpdateMonitorSubmitter = Callable[[Coroutine[Any, Any, None]], Any]
UpdateOperationRunner = Callable[..., Awaitable[Any]]
RestartMarker = Callable[[NotificationChannel, Union[str, int], Optional[str]], None]
RestartMarkerClearer = Callable[[], None]
@dataclass(frozen=True, slots=True)
class SystemUpdateInteractionView:
"""
保存一次渠道无关的更新交互展示内容。
:param title: 更新状态消息的标题
:param text: 向用户展示的版本、进度或操作说明
:param buttons: 按行组织的操作按钮,None 表示不附带按钮
"""
title: str
text: str
buttons: Optional[list[list[dict[str, str]]]] = None
class SystemUpdateInteractionHandler:
"""
编排 `/update` 的检查、下载进度编辑和重启确认流程。
"""
_poll_interval_seconds = 3.0
def __init__(
self,
*,
messenger: MessageGateway,
actions: SystemUpdateInteractionActions,
submit_monitor: UpdateMonitorSubmitter,
run_sync: UpdateOperationRunner,
mark_restart: RestartMarker,
clear_restart_marker: RestartMarkerClearer,
poll_interval_seconds: float = _poll_interval_seconds,
) -> None:
"""
注入消息网关、系统更新用例和受管后台任务提交器。
"""
self._messenger = messenger
self._actions = actions
self._mark_restart = mark_restart
self._clear_restart_marker = clear_restart_marker
self._renderer = _SystemUpdateRenderer(messenger=messenger, actions=actions)
self._progress_monitor = _SystemUpdateProgressMonitor(
actions=actions, renderer=self._renderer, submit_monitor=submit_monitor,
run_sync=run_sync,
poll_interval_seconds=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._renderer.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._renderer.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._renderer.render_check_failure(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
error=str(error),
)
return True
self._renderer.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._renderer.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._renderer.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._renderer.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._renderer.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._renderer.application_item(status)
if result.success and item.state == "downloading":
self._progress_monitor.schedule(
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._renderer.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._renderer.application_item(status)
if item.state != "ready":
self._renderer.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._renderer.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._renderer.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._renderer.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,
)
class _SystemUpdateProgressMonitor:
"""
管理主程序更新下载的后台轮询与消息刷新。
"""
_terminal_download_states = {"idle", "available", "ready", "failed", "installing"}
def __init__(
self, *, actions: SystemUpdateInteractionActions,
renderer: _SystemUpdateRenderer, submit_monitor: UpdateMonitorSubmitter,
run_sync: UpdateOperationRunner,
poll_interval_seconds: float,
) -> None:
"""
注入状态读取、消息渲染和后台任务提交能力。
"""
self._actions = actions
self._renderer = renderer
self._submit_monitor = submit_monitor
self._run_sync = run_sync
self._poll_interval_seconds = max(0.0, poll_interval_seconds)
def schedule(
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._renderer.post_view(
view=SystemUpdateInteractionView(
title="更新包已开始下载",
text="自动进度更新暂不可用,可重新发送 /update 查看状态。",
),
channel=channel, source=source, userid=userid, username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
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._renderer.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 self._run_sync(self._actions.update_status)
item = self._renderer.application_item(status)
fingerprint = self._renderer.item_fingerprint(item)
if fingerprint != last_fingerprint:
request.awaiting_input = self._renderer.awaiting_input(item)
view = self._renderer.build_view(
request=request,
item=item,
channel=channel,
)
if original_message_id and original_chat_id and ChannelCapabilityManager.supports_editing(channel):
edited = await self._run_sync(
self._renderer.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 self._run_sync(
self._renderer.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 self._run_sync(
self._renderer.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 self._run_sync(
self._renderer.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)
class _SystemUpdateRenderer:
"""
把主程序更新状态转换为渠道消息并负责发送或编辑。
"""
def __init__(
self, *, messenger: MessageGateway, actions: SystemUpdateInteractionActions,
) -> None:
"""
注入消息网关与更新状态读取用例。
"""
self._messenger = messenger
self._actions = actions
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,
)
)