mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
611 lines
20 KiB
Python
611 lines
20 KiB
Python
import re
|
||
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
|
||
|
||
from app.domain import site as site_rules
|
||
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.runtime.log import logger
|
||
from app.schemas.message import Message
|
||
from app.schemas.types import NotificationChannel
|
||
|
||
|
||
site_interaction_manager = SlashInteractionManager()
|
||
|
||
|
||
class SiteInteractionRepository(Protocol):
|
||
"""站点消息交互所需的同步数据端口。"""
|
||
|
||
def list(self) -> List[Any]:
|
||
"""返回站点列表。"""
|
||
|
||
def get(self, site_id: int) -> Optional[Any]:
|
||
"""按 ID 返回站点。"""
|
||
|
||
def update(self, site_id: int, payload: dict) -> Optional[Any]:
|
||
"""更新站点。"""
|
||
|
||
|
||
class SiteInteractionHandler:
|
||
"""
|
||
管理 /sites 交互会话、输入解析和站点列表渲染。
|
||
"""
|
||
|
||
_button_page_size = 6
|
||
_text_page_size = 10
|
||
|
||
def __init__(
|
||
self,
|
||
messenger: MessageGateway,
|
||
cookie_updater: Callable[..., Tuple[bool, str]],
|
||
repository: SiteInteractionRepository,
|
||
):
|
||
"""
|
||
注入消息投递接口和站点 Cookie 更新动作。
|
||
"""
|
||
self._messenger = messenger
|
||
self._cookie_updater = cookie_updater
|
||
self._repository = repository
|
||
|
||
def remote_list(
|
||
self,
|
||
arg_str: str = "",
|
||
channel: NotificationChannel = None,
|
||
userid: Union[str, int] = None,
|
||
source: Optional[str] = None,
|
||
):
|
||
"""
|
||
/sites 统一入口。
|
||
"""
|
||
request = site_interaction_manager.create_or_replace(
|
||
user_id=userid,
|
||
command="/sites",
|
||
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_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username="",
|
||
)
|
||
|
||
@staticmethod
|
||
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
|
||
"""
|
||
解析 /sites 按钮回调。
|
||
"""
|
||
if not callback_data.startswith("sites:"):
|
||
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:
|
||
"""
|
||
处理 /sites 按钮交互。
|
||
"""
|
||
parsed = self.parse_callback(callback_data)
|
||
if not parsed:
|
||
return False
|
||
|
||
request_id, action = parsed
|
||
request = site_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="站点交互已失效,请重新发送 /sites",
|
||
)
|
||
)
|
||
return True
|
||
|
||
request.channel = channel
|
||
request.source = source
|
||
request.username = username
|
||
|
||
if action == "close":
|
||
site_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 {"cookie", "enable", "disable"}:
|
||
request.awaiting_input = action
|
||
elif action == "refresh":
|
||
request.awaiting_input = None
|
||
|
||
self._render_site_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:
|
||
"""处理 /sites 文本交互并保持消息链公开 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:
|
||
"""
|
||
处理 /sites 文本补充输入。
|
||
"""
|
||
request = site_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"}:
|
||
site_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_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if lowered in {"刷新", "refresh", "列表", "list"}:
|
||
request.awaiting_input = None
|
||
self._render_site_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_site_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_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
cookie_match = re.match(
|
||
r"^(?:cookie|更新cookie|更新\s*cookie)\s+(.+)$",
|
||
normalized,
|
||
re.IGNORECASE,
|
||
)
|
||
enable_match = re.match(r"^(?:启用|enable)\s+(.+)$", normalized, re.IGNORECASE)
|
||
disable_match = re.match(
|
||
r"^(?:禁用|disable)\s+(.+)$", normalized, re.IGNORECASE
|
||
)
|
||
|
||
if request.awaiting_input == "cookie":
|
||
success, message = self._update_site_cookie_from_input(normalized)
|
||
request.awaiting_input = None
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if request.awaiting_input == "enable":
|
||
success, message = self._set_sites_enabled(normalized, enabled=True)
|
||
request.awaiting_input = None
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if request.awaiting_input == "disable":
|
||
success, message = self._set_sites_enabled(normalized, enabled=False)
|
||
request.awaiting_input = None
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if cookie_match:
|
||
success, message = self._update_site_cookie_from_input(cookie_match.group(1))
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if enable_match:
|
||
success, message = self._set_sites_enabled(enable_match.group(1), enabled=True)
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_interaction(
|
||
request=request,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
if disable_match:
|
||
success, message = self._set_sites_enabled(
|
||
disable_match.group(1), enabled=False
|
||
)
|
||
self._messenger.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=message,
|
||
)
|
||
)
|
||
self._render_site_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._site_usage_hint(request.awaiting_input),
|
||
)
|
||
)
|
||
return True
|
||
|
||
def _render_site_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:
|
||
"""
|
||
渲染 /sites 当前页面。
|
||
"""
|
||
site_list = self._repository.list()
|
||
page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size
|
||
page_sites, page, total_pages = page_items(site_list, request.page, page_size)
|
||
request.page = page
|
||
|
||
if site_list:
|
||
body = self._format_site_list(page_sites, channel=channel)
|
||
footer = [
|
||
f"第 {page + 1}/{total_pages} 页,共 {len(site_list)} 个站点",
|
||
self._site_prompt(request.awaiting_input),
|
||
self._site_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("sites", request, page, total_pages)
|
||
buttons.extend(
|
||
[
|
||
[
|
||
{
|
||
"text": "更新 Cookie",
|
||
"callback_data": f"sites:{request.request_id}:cookie",
|
||
},
|
||
{
|
||
"text": "禁用站点",
|
||
"callback_data": f"sites:{request.request_id}:disable",
|
||
},
|
||
{
|
||
"text": "启用站点",
|
||
"callback_data": f"sites:{request.request_id}:enable",
|
||
},
|
||
],
|
||
[
|
||
{
|
||
"text": "刷新列表",
|
||
"callback_data": f"sites:{request.request_id}:refresh",
|
||
},
|
||
{
|
||
"text": "关闭",
|
||
"callback_data": f"sites:{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,
|
||
)
|
||
|
||
@staticmethod
|
||
def _format_site_list(
|
||
site_list: List[Any], channel: Optional[NotificationChannel]
|
||
) -> str:
|
||
"""
|
||
根据渠道能力格式化站点列表。
|
||
"""
|
||
if supports_markdown(channel):
|
||
rows = [
|
||
[
|
||
site.id,
|
||
site.name,
|
||
"启用" if site.is_active else "禁用",
|
||
"已配置" if site.cookie else "未配置",
|
||
"是" if site.render else "否",
|
||
site.domain or site_rules.extract_domain(site.url or ""),
|
||
]
|
||
for site in site_list
|
||
]
|
||
return format_markdown_table(
|
||
headers=["ID", "站点", "状态", "Cookie", "渲染", "域名"],
|
||
rows=rows,
|
||
)
|
||
|
||
lines = []
|
||
for site in site_list:
|
||
lines.append(
|
||
f"{site.id}. {site.name} | 状态:{'启用' if site.is_active else '禁用'}"
|
||
f" | Cookie:{'已配置' if site.cookie else '未配置'}"
|
||
f" | 渲染:{'是' if site.render else '否'}"
|
||
f" | 域名:{site.domain or site_rules.extract_domain(site.url or '')}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def _site_prompt(awaiting_input: Optional[str]) -> str:
|
||
"""
|
||
返回当前输入模式提示。
|
||
"""
|
||
if awaiting_input == "cookie":
|
||
return "当前操作:更新站点 Cookie,请输入:<id> <username> <password> [2fa_code/secret]"
|
||
if awaiting_input == "enable":
|
||
return "当前操作:启用站点,请输入站点 ID,多个 ID 用空格分隔。"
|
||
if awaiting_input == "disable":
|
||
return "当前操作:禁用站点,请输入站点 ID,多个 ID 用空格分隔。"
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _site_usage_hint(awaiting_input: Optional[str]) -> str:
|
||
"""
|
||
返回 /sites 的文本操作提示。
|
||
"""
|
||
if awaiting_input == "cookie":
|
||
return "输入站点 ID、用户名、密码和可选 2FA;输入 `取消` 返回列表,输入 `退出` 结束交互。"
|
||
if awaiting_input in {"enable", "disable"}:
|
||
return "输入一个或多个站点 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
|
||
return (
|
||
"可输入:`cookie <id> <username> <password> [2fa]`、`启用 <id...>`、`禁用 <id...>`、"
|
||
"`n`、`p`、`刷新`、`退出`。"
|
||
)
|
||
|
||
@staticmethod
|
||
def _parse_site_ids(arg_str: str) -> List[int]:
|
||
"""
|
||
从输入中提取站点 ID。
|
||
"""
|
||
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
|
||
|
||
def _set_sites_enabled(self, arg_str: str, enabled: bool) -> Tuple[bool, str]:
|
||
"""
|
||
批量启用或禁用站点。
|
||
"""
|
||
site_ids = self._parse_site_ids(arg_str)
|
||
if not site_ids:
|
||
return False, "请输入至少一个有效的站点 ID"
|
||
|
||
changed = []
|
||
missing = []
|
||
for site_id in site_ids:
|
||
site = self._repository.get(site_id)
|
||
if not site:
|
||
missing.append(str(site_id))
|
||
continue
|
||
self._repository.update(site_id, {"is_active": enabled})
|
||
changed.append(site.name)
|
||
|
||
action = "启用" if enabled else "禁用"
|
||
if not changed and missing:
|
||
return False, f"未找到站点:{', '.join(missing)}"
|
||
|
||
message = f"已{action} {len(changed)} 个站点"
|
||
if changed:
|
||
message += f":{', '.join(changed)}"
|
||
if missing:
|
||
message += f";未找到:{', '.join(missing)}"
|
||
return True, message
|
||
|
||
def _update_site_cookie_from_input(self, arg_str: str) -> Tuple[bool, str]:
|
||
"""
|
||
根据输入更新单个站点 Cookie。
|
||
"""
|
||
args = str(arg_str or "").split()
|
||
if len(args) not in {3, 4} or not args[0].isdigit():
|
||
return (
|
||
False,
|
||
"格式错误,请输入:cookie <id> <username> <password> [2fa_code/secret]",
|
||
)
|
||
|
||
site_id = int(args[0])
|
||
site_info = self._repository.get(site_id)
|
||
if not site_info:
|
||
return False, f"站点编号 {site_id} 不存在"
|
||
|
||
status, msg = self._cookie_updater(
|
||
site_info=site_info,
|
||
username=args[1],
|
||
password=args[2],
|
||
two_step_code=args[3] if len(args) == 4 else None,
|
||
)
|
||
if not status:
|
||
logger.error(msg)
|
||
return False, f"【{site_info.name}】Cookie&UA 更新失败:{msg}"
|
||
return True, f"【{site_info.name}】Cookie&UA 更新成功"
|