mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
749 lines
24 KiB
Python
749 lines
24 KiB
Python
import re
|
||
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
|
||
|
||
from app.application.messaging.interaction import (
|
||
MessageGateway,
|
||
SlashInteractionManager,
|
||
build_navigation_buttons,
|
||
format_markdown_table,
|
||
page_items,
|
||
supports_interaction_buttons,
|
||
supports_markdown,
|
||
update_or_post_message,
|
||
)
|
||
from app.schemas.message import Message
|
||
from app.schemas.types import NotificationChannel, MediaType
|
||
|
||
|
||
subscribe_interaction_manager = SlashInteractionManager()
|
||
|
||
|
||
class SubscribeInteractionActions(Protocol):
|
||
"""
|
||
声明订阅交互需要调用的业务动作。
|
||
"""
|
||
|
||
def refresh(self) -> Any:
|
||
"""执行订阅刷新。"""
|
||
...
|
||
|
||
def check(self) -> Any:
|
||
"""执行订阅元数据检查。"""
|
||
...
|
||
|
||
def search(self, **kwargs: Any) -> Any:
|
||
"""按消息入口参数执行订阅搜索。"""
|
||
...
|
||
|
||
|
||
class SubscribeInteractionRepository(Protocol):
|
||
"""订阅消息交互所需的同步数据端口。"""
|
||
|
||
def list(self) -> List[Any]:
|
||
"""返回订阅列表。"""
|
||
|
||
def get(self, subscribe_id: int) -> Optional[Any]:
|
||
"""按 ID 返回订阅。"""
|
||
|
||
def check(self) -> Any:
|
||
"""执行订阅元数据检查。"""
|
||
...
|
||
|
||
def search(self, **kwargs: Any) -> Any:
|
||
"""执行订阅搜索。"""
|
||
...
|
||
|
||
|
||
class SubscribeInteractionHandler:
|
||
"""
|
||
管理 /subscribes 交互会话、按钮、文本输入和列表视图。
|
||
"""
|
||
|
||
_button_page_size = 6
|
||
_text_page_size = 10
|
||
|
||
def __init__(
|
||
self,
|
||
messenger: MessageGateway,
|
||
actions: SubscribeInteractionActions,
|
||
repository: SubscribeInteractionRepository,
|
||
delete_subscription: Callable[[int], bool],
|
||
) -> None:
|
||
"""
|
||
注入消息投递接口和订阅业务动作。
|
||
"""
|
||
self._messenger = messenger
|
||
self._actions = actions
|
||
self._repository = repository
|
||
self._delete_subscription = delete_subscription
|
||
|
||
def remote_list(
|
||
self,
|
||
arg_str: str = "",
|
||
channel: NotificationChannel = None,
|
||
userid: Union[str, int] = None,
|
||
source: Optional[str] = None,
|
||
):
|
||
"""
|
||
/subscribes 统一入口。
|
||
"""
|
||
request = subscribe_interaction_manager.create_or_replace(
|
||
user_id=userid,
|
||
command="/subscribes",
|
||
channel=channel,
|
||
source=source,
|
||
username=None,
|
||
)
|
||
normalized_arg = (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_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username="",
|
||
)
|
||
|
||
@staticmethod
|
||
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
|
||
"""
|
||
解析 /subscribes 按钮回调。
|
||
"""
|
||
if not callback_data.startswith("subscribes:"):
|
||
return None
|
||
parts = callback_data.split(":")
|
||
if len(parts) < 3:
|
||
return None
|
||
return parts[1], parts[2]
|
||
|
||
def handle_callback_interaction(
|
||
self,
|
||
callback_data: str,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
) -> bool:
|
||
"""
|
||
处理 /subscribes 按钮交互。
|
||
"""
|
||
parsed = self.parse_callback(callback_data)
|
||
if not parsed:
|
||
return False
|
||
|
||
request_id, action = parsed
|
||
request = subscribe_interaction_manager.get_by_id(request_id, userid)
|
||
if not request:
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="订阅交互已失效,请重新发送 /subscribes",
|
||
)
|
||
)
|
||
return True
|
||
|
||
request.channel = channel
|
||
request.source = source
|
||
request.username = username
|
||
|
||
if action == "close":
|
||
subscribe_interaction_manager.remove(request.request_id)
|
||
update_or_post_message(
|
||
chain=self._messenger,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="订阅管理",
|
||
text="订阅交互已结束",
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
)
|
||
return True
|
||
|
||
if action == "page-prev":
|
||
request.page = max(0, request.page - 1)
|
||
request.awaiting_input = None
|
||
elif action == "page-next":
|
||
request.page += 1
|
||
request.awaiting_input = None
|
||
elif action in {"search", "delete"}:
|
||
request.awaiting_input = action
|
||
elif action == "refresh":
|
||
request.awaiting_input = None
|
||
self._run_refresh_action(channel, source, userid, username)
|
||
elif action == "refresh-list":
|
||
request.awaiting_input = None
|
||
elif action == "metadata":
|
||
request.awaiting_input = None
|
||
self._run_metadata_refresh_action(channel, source, userid, username)
|
||
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
)
|
||
return True
|
||
|
||
def handle_text_interaction(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: str,
|
||
) -> bool:
|
||
"""处理 /subscribes 文本交互并保持消息链公开 ABI。"""
|
||
return self._handle_text_interaction(channel, source, userid, username, text)
|
||
|
||
def _handle_text_interaction(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: str,
|
||
) -> bool:
|
||
"""
|
||
处理 /subscribes 文本补充输入。
|
||
"""
|
||
request = subscribe_interaction_manager.get_by_user(userid)
|
||
if not request:
|
||
return False
|
||
|
||
request.channel = channel
|
||
request.source = source
|
||
request.username = username
|
||
|
||
normalized = (text or "").strip()
|
||
lowered = normalized.lower()
|
||
|
||
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
|
||
subscribe_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 lowered in {"取消", "cancel", "返回", "back"}:
|
||
request.awaiting_input = None
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"刷新列表", "列表", "list"}:
|
||
request.awaiting_input = None
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"刷新", "refresh"}:
|
||
request.awaiting_input = None
|
||
self._run_refresh_action(channel, source, userid, username)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"元数据", "刷新元数据", "metadata"}:
|
||
request.awaiting_input = None
|
||
self._run_metadata_refresh_action(channel, source, userid, username)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"p", "prev", "上一页"}:
|
||
request.awaiting_input = None
|
||
request.page = max(0, request.page - 1)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"n", "next", "下一页"}:
|
||
request.awaiting_input = None
|
||
request.page += 1
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
search_match = re.match(r"^(?:搜索|search)\s+(.+)$", normalized, re.IGNORECASE)
|
||
delete_match = re.match(r"^(?:删除|delete)\s+(.+)$", normalized, re.IGNORECASE)
|
||
|
||
if request.awaiting_input == "search":
|
||
success, message = self._run_search_action(
|
||
normalized, channel, source, userid, username
|
||
)
|
||
request.awaiting_input = None
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if request.awaiting_input == "delete":
|
||
success, message = self._delete_subscribes(normalized)
|
||
request.awaiting_input = None
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if search_match:
|
||
success, message = self._run_search_action(
|
||
search_match.group(1), channel, source, userid, username
|
||
)
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if delete_match:
|
||
success, message = self._delete_subscribes(delete_match.group(1))
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_subscribe_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=self._subscribe_usage_hint(request.awaiting_input),
|
||
)
|
||
)
|
||
return True
|
||
|
||
def _render_subscribe_interaction(
|
||
self,
|
||
request,
|
||
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:
|
||
"""
|
||
渲染 /subscribes 当前页面。
|
||
"""
|
||
subscribes = self._repository.list()
|
||
page_size = (
|
||
self._button_page_size
|
||
if supports_interaction_buttons(channel)
|
||
else self._text_page_size
|
||
)
|
||
page_subscribes, page, total_pages = page_items(
|
||
subscribes, request.page, page_size
|
||
)
|
||
request.page = page
|
||
|
||
if subscribes:
|
||
body = self._format_subscribe_list(page_subscribes, channel=channel)
|
||
footer = [
|
||
f"第 {page + 1}/{total_pages} 页,共 {len(subscribes)} 个订阅",
|
||
self._subscribe_prompt(request.awaiting_input),
|
||
self._subscribe_usage_hint(request.awaiting_input),
|
||
]
|
||
text = "\n\n".join([body, *[line for line in footer if line]])
|
||
else:
|
||
text = "当前没有任何订阅。\n\n输入 `退出` 结束交互。"
|
||
|
||
buttons = None
|
||
if supports_interaction_buttons(channel):
|
||
buttons = build_navigation_buttons(
|
||
"subscribes", request, page, total_pages
|
||
)
|
||
buttons.extend(
|
||
[
|
||
[
|
||
{
|
||
"text": "搜索订阅",
|
||
"callback_data": f"subscribes:{request.request_id}:search",
|
||
},
|
||
{
|
||
"text": "删除订阅",
|
||
"callback_data": f"subscribes:{request.request_id}:delete",
|
||
},
|
||
{
|
||
"text": "刷新订阅",
|
||
"callback_data": f"subscribes:{request.request_id}:refresh",
|
||
},
|
||
],
|
||
[
|
||
{
|
||
"text": "刷新元数据",
|
||
"callback_data": f"subscribes:{request.request_id}:metadata",
|
||
},
|
||
{
|
||
"text": "刷新列表",
|
||
"callback_data": f"subscribes:{request.request_id}:refresh-list",
|
||
},
|
||
{
|
||
"text": "关闭",
|
||
"callback_data": f"subscribes:{request.request_id}:close",
|
||
},
|
||
],
|
||
]
|
||
)
|
||
|
||
update_or_post_message(
|
||
chain=self._messenger,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="订阅管理",
|
||
text=text,
|
||
buttons=buttons,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
)
|
||
|
||
def _format_subscribe_list(
|
||
self, subscribes: List[Any], channel: Optional[NotificationChannel]
|
||
) -> str:
|
||
"""
|
||
根据渠道能力格式化订阅列表。
|
||
"""
|
||
if supports_markdown(channel):
|
||
rows = [
|
||
[
|
||
subscribe.id,
|
||
subscribe.name,
|
||
subscribe.type,
|
||
subscribe.year or "-",
|
||
self._format_subscribe_progress(subscribe),
|
||
self._format_subscribe_state(subscribe.state),
|
||
]
|
||
for subscribe in subscribes
|
||
]
|
||
return format_markdown_table(
|
||
headers=["ID", "名称", "类型", "年份", "季/进度", "状态"],
|
||
rows=rows,
|
||
)
|
||
|
||
lines = []
|
||
for subscribe in subscribes:
|
||
lines.append(
|
||
f"{subscribe.id}. {subscribe.name}({subscribe.year or '-'})"
|
||
f" | {subscribe.type}"
|
||
f" | {self._format_subscribe_progress(subscribe)}"
|
||
f" | 状态:{self._format_subscribe_state(subscribe.state)}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def _format_subscribe_state(state: Optional[str]) -> str:
|
||
"""
|
||
订阅状态显示文本。
|
||
"""
|
||
mapping = {
|
||
"N": "新建",
|
||
"R": "订阅中",
|
||
"P": "待定",
|
||
"S": "暂停",
|
||
}
|
||
return mapping.get(state or "", state or "-")
|
||
|
||
@staticmethod
|
||
def _format_subscribe_progress(subscribe: Any) -> str:
|
||
"""
|
||
构造订阅的季和进度说明。
|
||
"""
|
||
if subscribe.type == MediaType.MOVIE.value:
|
||
return "电影"
|
||
season = subscribe.season if subscribe.season is not None else 1
|
||
if subscribe.total_episode:
|
||
lack_episode = (
|
||
subscribe.lack_episode
|
||
if subscribe.lack_episode is not None
|
||
else subscribe.total_episode
|
||
)
|
||
downloaded = max(subscribe.total_episode - lack_episode, 0)
|
||
return f"第{season}季 [{downloaded}/{subscribe.total_episode}]"
|
||
return f"第{season}季"
|
||
|
||
@staticmethod
|
||
def _subscribe_prompt(awaiting_input: Optional[str]) -> str:
|
||
"""
|
||
返回当前输入模式提示。
|
||
"""
|
||
if awaiting_input == "search":
|
||
return "当前操作:搜索订阅,请输入订阅 ID,多个 ID 用空格分隔,或输入 all 搜索全部。"
|
||
if awaiting_input == "delete":
|
||
return "当前操作:删除订阅,请输入订阅 ID,多个 ID 用空格分隔。"
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _subscribe_usage_hint(awaiting_input: Optional[str]) -> str:
|
||
"""
|
||
返回 /subscribes 的文本操作提示。
|
||
"""
|
||
if awaiting_input == "search":
|
||
return "输入订阅 ID 或 all;输入 `取消` 返回列表,输入 `退出` 结束交互。"
|
||
if awaiting_input == "delete":
|
||
return "输入一个或多个订阅 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
|
||
return (
|
||
"可输入:`搜索 <id...|all>`、`删除 <id...>`、`刷新`、`刷新元数据`、`n`、`p`、`退出`。"
|
||
)
|
||
|
||
def _run_refresh_action(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> None:
|
||
"""
|
||
执行订阅刷新。
|
||
"""
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="开始刷新订阅...",
|
||
)
|
||
)
|
||
self._actions.refresh()
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="订阅刷新执行完成",
|
||
)
|
||
)
|
||
|
||
def _run_metadata_refresh_action(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> None:
|
||
"""
|
||
执行订阅元数据刷新。
|
||
"""
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="开始刷新订阅元数据...",
|
||
)
|
||
)
|
||
self._actions.check()
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="订阅元数据刷新完成",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _parse_subscribe_ids(arg_str: str) -> List[int]:
|
||
"""
|
||
从输入中提取订阅 ID。
|
||
"""
|
||
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
|
||
|
||
def _run_search_action(
|
||
self,
|
||
arg_str: str,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
手动执行订阅搜索。
|
||
"""
|
||
normalized = (arg_str or "").strip()
|
||
if not normalized or normalized.lower() in {"all", "全部", "所有"}:
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="开始搜索所有订阅...",
|
||
)
|
||
)
|
||
self._actions.search(state="N,R,P", manual=True)
|
||
return True, "所有订阅搜索完成"
|
||
|
||
subscribe_ids = self._parse_subscribe_ids(normalized)
|
||
if not subscribe_ids:
|
||
return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all"
|
||
|
||
missing = []
|
||
searched = []
|
||
for subscribe_id in subscribe_ids:
|
||
subscribe = self._repository.get(subscribe_id)
|
||
if not subscribe:
|
||
missing.append(str(subscribe_id))
|
||
continue
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=f"开始搜索订阅【{subscribe.name}】...",
|
||
)
|
||
)
|
||
self._actions.search(sid=subscribe_id, manual=True)
|
||
searched.append(subscribe.name)
|
||
|
||
if not searched and missing:
|
||
return False, f"未找到订阅:{', '.join(missing)}"
|
||
|
||
message = f"已完成 {len(searched)} 个订阅搜索"
|
||
if searched:
|
||
message += f":{', '.join(searched)}"
|
||
if missing:
|
||
message += f";未找到:{', '.join(missing)}"
|
||
return True, message
|
||
|
||
def _delete_subscribes(self, arg_str: str) -> Tuple[bool, str]:
|
||
"""
|
||
批量删除订阅。
|
||
"""
|
||
subscribe_ids = self._parse_subscribe_ids(arg_str)
|
||
if not subscribe_ids:
|
||
return False, "请输入至少一个有效的订阅 ID"
|
||
|
||
deleted = []
|
||
missing = []
|
||
for subscribe_id in subscribe_ids:
|
||
subscribe = self._repository.get(subscribe_id)
|
||
if not subscribe:
|
||
missing.append(str(subscribe_id))
|
||
continue
|
||
if not self._delete_subscription(subscribe_id):
|
||
missing.append(str(subscribe_id))
|
||
continue
|
||
deleted.append(subscribe.name)
|
||
|
||
if not deleted and missing:
|
||
return False, f"未找到订阅:{', '.join(missing)}"
|
||
|
||
message = f"已删除 {len(deleted)} 个订阅"
|
||
if deleted:
|
||
message += f":{', '.join(deleted)}"
|
||
if missing:
|
||
message += f";未找到:{', '.join(missing)}"
|
||
return True, message
|