From dd38c164002b59bbfc08d5cff532483b1089344a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 15 Aug 2026 16:36:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(messaging):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92=E6=A8=A1=E5=9D=97=E5=88=B0?= =?UTF-8?q?=20application/messaging=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 application/messaging 交互层:router.py 统一会话优先级与回调分发, site/subscribe/skill/media/plugin 各交互状态与视图从 Chain 迁出 - MessageChain 改为通过 InteractionRouter 派发文本会话与按钮回调, 新增结构化 callback_data 通道(兼容 CALLBACK: 文本前缀) - Transfer 失败重试/AI 接管回调归入 TransferChain - MediaInteractionChain 拆出为 app/chain/interaction.py(旧路径保留兼容别名) - WebAgent Endpoint 去重,统一使用 agent.py 回调协议函数 - 删除 app/chain/skills.py(交互逻辑并入 SkillInteractionHandler) - 同步更新架构文档与测试,全量 4476 通过 --- AGENTS.md | 2 +- app/agent/tools/impl/ask_user_choice.py | 7 +- app/api/endpoints/agent.py | 98 +- app/application/messaging/agent.py | 170 +- app/application/messaging/interaction.py | 774 +----- app/application/messaging/media.py | 151 ++ app/application/messaging/plugin.py | 504 ++++ app/application/messaging/router.py | 121 + app/application/messaging/site.py | 587 +++++ .../messaging/skill.py} | 209 +- app/application/messaging/subscribe.py | 725 +++++ app/chain/interaction.py | 1567 +++++++++++ app/chain/message.py | 2322 ++--------------- app/chain/site.py | 521 +--- app/chain/subscribe.py | 642 +---- app/chain/transfer.py | 204 ++ app/command.py | 6 +- app/runtime/compat/manifest.py | 13 + app/schemas/message.py | 2 + docs/rules/01-project-overview.md | 2 +- docs/rules/05-architecture.md | 2 +- tests/test_agent_interaction.py | 36 +- tests/test_agent_message_routing.py | 24 +- tests/test_interaction_router.py | 193 ++ tests/test_media_interaction.py | 49 +- tests/test_skills_command.py | 88 +- tests/test_slash_command_interactions.py | 33 +- tests/test_subscribe_chain.py | 6 +- tests/test_transfer_failed_retry_buttons.py | 57 +- tests/test_web_agent_stream.py | 9 +- 30 files changed, 4894 insertions(+), 4230 deletions(-) create mode 100644 app/application/messaging/media.py create mode 100644 app/application/messaging/plugin.py create mode 100644 app/application/messaging/router.py create mode 100644 app/application/messaging/site.py rename app/{chain/skills.py => application/messaging/skill.py} (86%) create mode 100644 app/application/messaging/subscribe.py create mode 100644 app/chain/interaction.py create mode 100644 tests/test_interaction_router.py diff --git a/AGENTS.md b/AGENTS.md index 0d4ebb5e0..36e8c356d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ The historical `app/core`, `app/helper`, and `app/utils` directories are compati | `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `stdio.py`, `package.py`, `resource.py`, `rust.py` | | `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py` | | `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` | -| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接 | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `agent.py` | +| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` | | `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` | | `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches | Transport schemas, backend-specific protocol details, generic primitives | `media.py`, `download.py`, `subscribe.py`, `transfer.py` | | `app/startup/` | Composition root: inject providers/adapters, order initialization and shutdown, decide restart/lifecycle policy | Reusable business rules or adapter implementation details | `lifecycle.py`, `domain_initializer.py`, `cache_initializer.py`, `modules_initializer.py` | diff --git a/app/agent/tools/impl/ask_user_choice.py b/app/agent/tools/impl/ask_user_choice.py index 104d28ad5..275d6305e 100644 --- a/app/agent/tools/impl/ask_user_choice.py +++ b/app/agent/tools/impl/ask_user_choice.py @@ -6,9 +6,10 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.application.messaging.interaction import ( +from app.application.messaging.agent import ( AgentInteractionOption, agent_interaction_manager, + build_agent_choice_callback, ) from app.runtime.log import logger from app.schemas import Notification, NotificationType @@ -180,8 +181,8 @@ class AskUserChoiceTool(MoviePilotTool): current_row.append( { "text": self._truncate_button_text(option.label, max_text_length), - "callback_data": ( - f"agent_interaction:choice:{request.request_id}:{index}" + "callback_data": build_agent_choice_callback( + request.request_id, index ), } ) diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index 6a8a29832..fb53ecd2b 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -25,9 +25,6 @@ from app.agent.orchestrator import MoviePilotAgent, ReplyMode, agent_manager from app.agent.llm.capability import AgentCapabilityManager from app.agent.mcp import agent_mcp_manager from app.chain.message import MessageChain -from app.chain.site import site_interaction_manager -from app.chain.skills import skills_interaction_manager -from app.chain.subscribe import subscribe_interaction_manager from app.command import Command from app.runtime.config import global_vars, settings from app.runtime.events import Event, EventManager @@ -38,7 +35,13 @@ from app.db.models.agentchat import AgentChat from app.db.oper.user import UserOper from app.api.deps import get_current_active_user from app.application.messaging.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue -from app.application.messaging.interaction import agent_interaction_manager, media_interaction_manager +from app.application.messaging.agent import agent_interaction_manager +from app.application.messaging.agent import ( + build_agent_choice_button_rows, + normalize_web_agent_button_rows, + parse_agent_choice_callback, +) +from app.application.messaging.router import has_pending_interaction from app.runtime.localization import LocaleHelper from app.runtime.log import logger from app.schemas.types import EventType, MessageChannel @@ -979,52 +982,6 @@ def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[st return "\n".join(merged_parts).strip() -def _parse_web_agent_choice_callback(callback_data: str) -> Optional[tuple[str, int]]: - """ - 解析 Web Agent 按钮选择回调数据。 - - :param callback_data: Agent 按钮携带的回调数据 - :return: 请求 ID 与选项序号,格式无效时返回 None - """ - if not callback_data.startswith("agent_interaction:choice:"): - return None - try: - _, _, request_id, option_index = callback_data.split(":", 3) - except ValueError: - return None - if not request_id or not option_index.isdigit(): - return None - return request_id, int(option_index) - - -def _normalize_web_agent_choice_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]: - """ - 将消息渠道按钮二维结构转换为 Web 前端可渲染的按钮行。 - - :param buttons: Notification 中的按钮行 - :return: Web 选择卡片按钮行 - """ - normalized_rows = [] - for row in buttons or []: - normalized_row = [] - for button in row or []: - text = str(button.get("text") or "").strip() - callback_data = str(button.get("callback_data") or "").strip() - if not text or not callback_data: - continue - description = str(button.get("description") or "").strip() - normalized_row.append( - { - "label": text, - "callback_data": callback_data, - **({"description": description} if description else {}), - } - ) - if normalized_row: - normalized_rows.append(normalized_row) - return normalized_rows - - def _build_web_agent_choice_event(notification: schemas.Notification) -> Optional[dict]: """ 将带按钮通知转换为 Web Agent 选择卡片事件。 @@ -1032,13 +989,13 @@ def _build_web_agent_choice_event(notification: schemas.Notification) -> Optiona :param notification: Agent 工具发出的按钮通知 :return: 选择卡片事件,按钮为空时返回 None """ - button_rows = _normalize_web_agent_choice_button_rows(notification.buttons) + button_rows = normalize_web_agent_button_rows(notification.buttons) buttons = [button for row in button_rows for button in row] if not buttons: return None choice_id = None - parsed = _parse_web_agent_choice_callback(buttons[0]["callback_data"]) + parsed = parse_agent_choice_callback(buttons[0]["callback_data"]) if parsed: choice_id = parsed[0] @@ -1054,27 +1011,6 @@ def _build_web_agent_choice_event(notification: schemas.Notification) -> Optiona } -def _build_web_agent_choice_buttons_from_request( - request, -) -> tuple[list[dict], list[list[dict]]]: - """ - 根据待处理交互请求重建可持久化的按钮列表与按钮行。 - - :param request: 等待用户选择的交互请求 - :return: 平铺按钮列表与按行分组的按钮结构 - """ - buttons = [ - { - "label": option.label, - "callback_data": f"agent_interaction:choice:{request.request_id}:{index}", - "description": option.description or option.label, - } - for index, option in enumerate(request.options, start=1) - ] - button_rows = [[button] for button in buttons] - return buttons, button_rows - - def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optional[dict]: """ 解析并消费 Web Agent 按钮选择,生成前端反馈与下一条用户消息。 @@ -1083,7 +1019,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio :param user_id: 当前登录用户 ID :return: 可返回给前端的数据,选择无效时返回 None """ - parsed = _parse_web_agent_choice_callback(callback_data) + parsed = parse_agent_choice_callback(callback_data) if not parsed: return None @@ -1097,7 +1033,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio return None request, option = resolved - buttons, button_rows = _build_web_agent_choice_buttons_from_request(request) + buttons, button_rows = build_agent_choice_button_rows(request) selected_description = option.description or option.label return { "message": option.value, @@ -1223,15 +1159,7 @@ def _has_web_agent_traditional_interaction(user_id: str) -> bool: :param user_id: 当前登录用户 ID :return: 存在传统交互上下文时返回 True """ - return any( - manager.get_by_user(user_id) - for manager in ( - site_interaction_manager, - subscribe_interaction_manager, - skills_interaction_manager, - media_interaction_manager, - ) - ) + return has_pending_interaction(user_id) def _extract_web_agent_notification_from_event_data( @@ -1695,7 +1623,7 @@ async def web_agent_callback( :param current_user: 当前登录用户 :return: 下一条需要发送给 Agent 的用户消息与卡片反馈 """ - if not _parse_web_agent_choice_callback(payload.callback_data): + if not parse_agent_choice_callback(payload.callback_data): denied_message = _ensure_web_agent_command_allowed(current_user) if denied_message: return schemas.Response(success=False, message=denied_message) diff --git a/app/application/messaging/agent.py b/app/application/messaging/agent.py index 5a183f4ce..1af31fc70 100644 --- a/app/application/messaging/agent.py +++ b/app/application/messaging/agent.py @@ -1,9 +1,177 @@ +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta from queue import Queue from threading import Lock -from typing import Callable, Iterable, Optional, Union +from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union from app.schemas.types import MessageChannel +# Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容) +AGENT_CHOICE_PREFIX = "agent_interaction:choice:" +LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:" + + +def build_agent_choice_callback(request_id: str, option_index: int) -> str: + """构造 Agent 选择按钮回调数据。""" + return f"{AGENT_CHOICE_PREFIX}{request_id}:{option_index}" + + +def parse_agent_choice_callback( + callback_data: str, +) -> Optional[Tuple[str, int]]: + """解析新旧两种 Agent 选择回调,格式无效时返回 None。""" + if callback_data.startswith(AGENT_CHOICE_PREFIX): + try: + _, _, request_id, option_index = callback_data.split(":", 3) + except ValueError: + return None + elif callback_data.startswith(LEGACY_AGENT_CHOICE_PREFIX): + # 兼容旧格式,避免已发送的按钮失效 + try: + _, request_id, option_index = callback_data.split(":", 2) + except ValueError: + return None + else: + return None + if not request_id or not option_index.isdigit(): + return None + return request_id, int(option_index) + + +def build_agent_choice_button_rows( + request: "PendingAgentInteraction", +) -> Tuple[List[dict], List[List[dict]]]: + """根据待选择请求构造 WebAgent 和消息渠道共用的按钮。""" + buttons = [ + { + "label": option.label, + "callback_data": build_agent_choice_callback(request.request_id, index), + "description": option.description or option.label, + } + for index, option in enumerate(request.options, start=1) + ] + button_rows = [[button] for button in buttons] + return buttons, button_rows + + +@dataclass(frozen=True) +class AgentInteractionOption: + """ + Agent 交互选项。 + """ + + label: str + value: str + description: Optional[str] = None + + +@dataclass +class PendingAgentInteraction: + """ + 待处理的 Agent 客户端交互请求。 + """ + + request_id: str + session_id: str + user_id: str + channel: Optional[str] + source: Optional[str] + username: Optional[str] + title: Optional[str] + prompt: str + options: List[AgentInteractionOption] + created_at: datetime = field(default_factory=datetime.now) + + +class AgentInteractionManager: + """ + 管理 Agent 发起的客户端交互请求。 + """ + + _ttl = timedelta(hours=24) + + def __init__(self): + """初始化待处理的 Agent 交互请求表。""" + self._pending_interactions: Dict[str, PendingAgentInteraction] = {} + self._lock = Lock() + + def _cleanup_locked(self) -> None: + """在持锁状态下移除过期 Agent 交互。""" + expire_before = datetime.now() - self._ttl + expired_ids = [ + request_id + for request_id, request in self._pending_interactions.items() + if request.created_at < expire_before + ] + for request_id in expired_ids: + self._pending_interactions.pop(request_id, None) + + def create_request( + self, + session_id: str, + user_id: str, + channel: Optional[str], + source: Optional[str], + username: Optional[str], + title: Optional[str], + prompt: str, + options: List[AgentInteractionOption], + ) -> PendingAgentInteraction: + """ + 创建一条待用户确认的 Agent 交互请求。 + """ + with self._lock: + self._cleanup_locked() + request_id = uuid.uuid4().hex[:12] + while request_id in self._pending_interactions: + request_id = uuid.uuid4().hex[:12] + request = PendingAgentInteraction( + request_id=request_id, + session_id=session_id, + user_id=str(user_id), + channel=channel, + source=source, + username=username, + title=title, + prompt=prompt, + options=options, + ) + self._pending_interactions[request_id] = request + return request + + def resolve( + self, + request_id: str, + option_index: int, + user_id: Optional[str] = None, + ) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]: + """ + 消费一条 Agent 交互请求,并返回选中的选项。 + """ + with self._lock: + self._cleanup_locked() + request = self._pending_interactions.get(request_id) + if not request: + return None + if user_id is not None and str(request.user_id) != str(user_id): + return None + if option_index < 1 or option_index > len(request.options): + return None + option = request.options[option_index - 1] + self._pending_interactions.pop(request_id, None) + return request, option + + def clear(self) -> None: + """ + 清空所有 Agent 交互请求。 + """ + with self._lock: + self._pending_interactions.clear() + + +agent_interaction_manager = AgentInteractionManager() + _WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {} _WEB_AGENT_EDIT_LOCK = Lock() diff --git a/app/application/messaging/interaction.py b/app/application/messaging/interaction.py index f3a4b8366..34ece492c 100644 --- a/app/application/messaging/interaction.py +++ b/app/application/messaging/interaction.py @@ -3,10 +3,8 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta from threading import Lock -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union -from app.domain.context import MediaInfo -from app.domain.meta.metabase import MetaBase from app.schemas import Notification from app.schemas.message import ChannelCapabilityManager from app.schemas.types import MessageChannel @@ -118,6 +116,35 @@ class SlashInteractionManager: self._by_user.clear() +@dataclass(frozen=True, slots=True) +class InteractionContext: + """描述一次与渠道无关的用户交互上下文。""" + + channel: MessageChannel + source: Optional[str] + user_id: Union[str, int] + username: Optional[str] + original_message_id: Optional[Union[str, int]] = None + original_chat_id: Optional[str] = None + is_channel_admin: Optional[bool] = None + + +@dataclass(frozen=True, slots=True) +class InteractionDispatch: + """描述交互路由是否命中以及是否延迟结束处理状态。""" + + handled: bool + defer_processing_finish: bool = False + + +class MessageGateway(Protocol): + """声明交互控制器使用的消息发送和编辑能力。""" + + def post_message(self, message: Notification): ... + + def edit_message(self, **kwargs) -> bool: ... + + def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool: """ 渠道同时支持按钮和回调时,优先使用按钮交互。 @@ -266,744 +293,3 @@ def format_markdown_table( for row in rows ] return "\n".join([header_line, separator_line, *data_lines]) - - -@dataclass -class PendingMediaInteraction: - """ - 记录一次搜索/下载/订阅交互的当前上下文。 - """ - - request_id: str - user_id: str - channel: Optional[MessageChannel] - source: Optional[str] - username: Optional[str] - action: str - keyword: str - phase: str = "media" - page: int = 0 - title: str = "" - meta: Optional[MetaBase] = None - current_media: Optional[MediaInfo] = None - items: List[Any] = field(default_factory=list) - download_dirs: List[Any] = field(default_factory=list) - pending_download_mode: Optional[str] = None - pending_download_context: Optional[Any] = None - pending_no_exists: Optional[Dict[Any, Any]] = None - pending_torrent_page: int = 0 - created_at: datetime = field(default_factory=datetime.now) - - -class MediaInteractionManager: - """ - 管理用户当前激活的媒体交互状态。 - - 每个用户只保留一个有效会话,避免旧按钮与新一轮搜索混用。 - """ - - _ttl = timedelta(hours=24) - - def __init__(self): - """初始化按请求和用户索引的媒体会话表。""" - self._by_id: Dict[str, PendingMediaInteraction] = {} - self._by_user: Dict[str, str] = {} - self._lock = Lock() - - def _cleanup_locked(self) -> None: - """ - 清理超时会话,避免内存中残留旧交互状态。 - """ - expire_before = datetime.now() - self._ttl - expired = [ - request_id - for request_id, request in self._by_id.items() - if request.created_at < expire_before - ] - for request_id in expired: - request = self._by_id.pop(request_id, None) - if request: - self._by_user.pop(str(request.user_id), None) - - def create_or_replace( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - username: Optional[str], - action: str, - keyword: str, - title: str = "", - meta: Optional[MetaBase] = None, - items: Optional[List[Any]] = None, - ) -> PendingMediaInteraction: - """ - 为用户创建新的交互状态,并替换旧会话。 - """ - with self._lock: - self._cleanup_locked() - user_key = str(user_id) - old_request_id = self._by_user.get(user_key) - if old_request_id: - self._by_id.pop(old_request_id, None) - - request = PendingMediaInteraction( - request_id=uuid.uuid4().hex[:12], - user_id=user_key, - channel=channel, - source=source, - username=username, - action=action, - keyword=keyword, - title=title, - meta=meta, - items=list(items or []), - ) - self._by_id[request.request_id] = request - self._by_user[user_key] = request.request_id - return request - - def get_by_user( - self, user_id: Union[str, int] - ) -> Optional[PendingMediaInteraction]: - """ - 按用户读取当前会话,供文本回复和旧按钮兼容使用。 - """ - with self._lock: - self._cleanup_locked() - request_id = self._by_user.get(str(user_id)) - if not request_id: - return None - return self._by_id.get(request_id) - - def get_by_id( - self, request_id: str, user_id: Union[str, int] - ) -> Optional[PendingMediaInteraction]: - """ - 按请求 ID 读取会话,并校验用户归属。 - """ - with self._lock: - self._cleanup_locked() - request = self._by_id.get(request_id) - if not request or str(request.user_id) != str(user_id): - return None - return request - - def remove(self, request_id: str) -> None: - """ - 主动结束一条会话。 - """ - with self._lock: - request = self._by_id.pop(request_id, None) - if request: - self._by_user.pop(str(request.user_id), None) - - def clear(self) -> None: - """ - 清空所有交互状态,主要用于测试。 - """ - with self._lock: - self._by_id.clear() - self._by_user.clear() - - -media_interaction_manager = MediaInteractionManager() - - -@dataclass -class PendingPluginInputInteraction: - """ - 记录插件临时接管用户下一条文本输入的会话。 - """ - - request_id: str - user_id: str - plugin_id: str - channel: Optional[MessageChannel] - source: Optional[str] - username: Optional[str] - chat_id: Optional[str] = None - prompt_id: Optional[str] = None - payload: Optional[Any] = None - timeout_seconds: int = 120 - created_at: datetime = field(default_factory=datetime.now) - # Optional reply binding for channels that can report reply_to_message_id. - prompt_message_id: Optional[str] = None - - @property - def expires_at(self) -> datetime: - """返回输入会话的绝对过期时间。""" - return self.created_at + timedelta(seconds=max(1, self.timeout_seconds)) - - -class PluginInputInteractionManager: - """ - 管理插件输入会话。 - - 会话按用户和渠道绑定;同一用户在同一渠道只保留一个待输入会话。 - """ - - EXPIRED_GRACE_SECONDS = 300 - - def __init__(self): - """初始化活动输入会话、用户渠道索引和过期墓碑。""" - self._by_id: Dict[str, PendingPluginInputInteraction] = {} - self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {} - self._expired_by_user_channel: Dict[ - Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], - PendingPluginInputInteraction, - ] = {} - self._lock = Lock() - - @staticmethod - def _user_channel_source_key( - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str] = None, - chat_id: Optional[Union[str, int]] = None, - ) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]: - """归一化用户、渠道、来源和会话 ID 的联合索引键。""" - return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None - - @classmethod - def _keys_overlap( - cls, - left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], - right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], - ) -> bool: - """判断两个输入会话键是否会争用同一条用户回复。""" - left_user, left_channel, left_source, left_chat_id = left - right_user, right_channel, right_source, right_chat_id = right - if left_user != right_user: - return False - if left_chat_id and right_chat_id and left_chat_id != right_chat_id: - return False - if (left_channel is None and left_source is None) or (right_channel is None and right_source is None): - return left_channel == right_channel and left_source == right_source - channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None - source_overlap = left_source == right_source or left_source is None or right_source is None - return channel_overlap and source_overlap - - def _cleanup_locked(self) -> None: - """在持锁状态下淘汰过期会话并维护短期过期墓碑。""" - now = datetime.now() - expired_tombstones = [ - key - for key, request in self._expired_by_user_channel.items() - if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now - ] - for key in expired_tombstones: - self._expired_by_user_channel.pop(key, None) - - expired = [ - request_id - for request_id, request in self._by_id.items() - if request.expires_at < now - ] - for request_id in expired: - request = self._by_id.pop(request_id, None) - if request: - key = self._user_channel_source_key( - request.user_id, - request.channel, - request.source, - request.chat_id, - ) - self._by_user_channel.pop(key, None) - self._expired_by_user_channel[key] = request - - def create_or_replace( - self, - user_id: Union[str, int], - plugin_id: str, - channel: Optional[MessageChannel], - source: Optional[str], - username: Optional[str], - chat_id: Optional[Union[str, int]] = None, - prompt_id: Optional[str] = None, - timeout_seconds: int = 120, - payload: Optional[Any] = None, - *, - prompt_message_id: Optional[Union[str, int]] = None, - ) -> PendingPluginInputInteraction: - """创建插件输入会话并替换键范围重叠的旧会话。""" - with self._lock: - self._cleanup_locked() - key = self._user_channel_source_key(user_id, channel, source, chat_id) - old_request_ids = [ - request_id - for stored_key, request_id in self._by_user_channel.items() - if self._keys_overlap(stored_key, key) - ] - for old_request_id in old_request_ids: - self._by_id.pop(old_request_id, None) - self._by_user_channel = { - stored_key: request_id - for stored_key, request_id in self._by_user_channel.items() - if request_id not in old_request_ids - } - self._expired_by_user_channel = { - stored_key: request - for stored_key, request in self._expired_by_user_channel.items() - if not self._keys_overlap(stored_key, key) - } - - normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None - normalized_prompt_message_id = ( - str(prompt_message_id) - if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "") - else None - ) - - request = PendingPluginInputInteraction( - request_id=uuid.uuid4().hex[:12], - user_id=str(user_id), - plugin_id=plugin_id, - channel=channel, - source=source, - username=username, - chat_id=normalized_chat_id, - prompt_id=prompt_id, - prompt_message_id=normalized_prompt_message_id, - timeout_seconds=timeout_seconds, - payload=payload, - ) - self._by_id[request.request_id] = request - self._by_user_channel[key] = request.request_id - return request - - def get_by_user( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel] = None, - source: Optional[str] = None, - chat_id: Optional[Union[str, int]] = None, - ) -> Optional[PendingPluginInputInteraction]: - """按用户和渠道上下文查询活动输入会话。""" - with self._lock: - self._cleanup_locked() - request_id = self._find_request_id_locked(user_id, channel, source, chat_id) - if request_id: - return self._by_id.get(request_id) - return None - - def pop_by_user( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel] = None, - source: Optional[str] = None, - chat_id: Optional[Union[str, int]] = None, - ) -> Optional[PendingPluginInputInteraction]: - """取出并删除活动或刚过期的输入会话。""" - with self._lock: - self._cleanup_locked() - key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) - if request_id: - self._by_user_channel.pop(key, None) - return self._by_id.pop(request_id, None) - expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id) - if expired_key: - self._expired_by_user_channel.pop(expired_key, None) - return request - - def consume_by_user( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel] = None, - source: Optional[str] = None, - chat_id: Optional[Union[str, int]] = None, - *, - reply_to_message_id: Optional[Union[str, int]] = None, - bypass_reply_check: bool = False, - ) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]: - """消费匹配回复的输入会话,并返回 active 或 expired 状态。""" - with self._lock: - key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) - - if request_id: - request = self._by_id.get(request_id) - if not request: - self._by_user_channel.pop(key, None) - elif request.expires_at < datetime.now(): - self._by_user_channel.pop(key, None) - self._by_id.pop(request_id, None) - if request.prompt_message_id: - return None, None - return request, "expired" - elif not self._reply_matches_prompt( - request, - chat_id, - reply_to_message_id, - ignore_reply_to_message_id=bypass_reply_check, - ): - return None, None - else: - self._by_user_channel.pop(key, None) - self._by_id.pop(request_id, None) - return request, "active" - self._cleanup_locked() - key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id) - if request: - self._expired_by_user_channel.pop(key, None) - if request.prompt_message_id: - return None, None - return request, "expired" - self._cleanup_locked() - return None, None - - @staticmethod - def _reply_matches_prompt( - request: PendingPluginInputInteraction, - chat_id: Optional[Union[str, int]], - reply_to_message_id: Optional[Union[str, int]], - *, - ignore_reply_to_message_id: bool = False, - ) -> bool: - """校验消息回复关系是否绑定到原始提示。""" - if not request.prompt_message_id: - return True - if not request.chat_id or chat_id in (None, ""): - return False - if str(chat_id) != str(request.chat_id): - return False - if ignore_reply_to_message_id: - return True - if reply_to_message_id in (None, ""): - return False - return str(reply_to_message_id) == str(request.prompt_message_id) - - def _find_request_id_locked( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - chat_id: Optional[Union[str, int]] = None, - ) -> Optional[str]: - """在持锁状态下查找活动请求 ID。""" - _, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) - return request_id - - def _find_key_and_request_id_locked( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - chat_id: Optional[Union[str, int]] = None, - ) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]: - """返回首个候选键及其活动请求 ID。""" - for key in self._candidate_keys(user_id, channel, source, chat_id): - request_id = self._by_user_channel.get(key) - if request_id: - return key, request_id - return None, None - - def _find_expired_key_and_request_locked( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - chat_id: Optional[Union[str, int]] = None, - ) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], - Optional[PendingPluginInputInteraction]]: - """返回仍在宽限期内的过期会话及其索引键。""" - now = datetime.now() - for key in self._candidate_keys(user_id, channel, source, chat_id): - request = self._expired_by_user_channel.get(key) - if not request: - continue - if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now: - self._expired_by_user_channel.pop(key, None) - continue - return key, request - return None, None - - def _candidate_keys( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - chat_id: Optional[Union[str, int]] = None, - ) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]: - """按精确到宽松顺序生成输入会话候选键。""" - chat_key = str(chat_id) if chat_id not in (None, "") else None - candidates = [ - self._user_channel_source_key(user_id, channel, source, chat_key), - ] - if source is not None: - candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key)) - if channel is not None and source is not None: - candidates.append(self._user_channel_source_key(user_id, None, source, chat_key)) - if channel is None and source is None: - wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key) - candidates.append(wildcard_key) - if chat_key is not None: - candidates.append(self._user_channel_source_key(user_id, channel, source, None)) - if source is not None: - candidates.append(self._user_channel_source_key(user_id, channel, None, None)) - if channel is not None and source is not None: - candidates.append(self._user_channel_source_key(user_id, None, source, None)) - if channel is None and source is None: - candidates.append(self._user_channel_source_key(user_id, None, None, None)) - return candidates - - def remove(self, request_id: str) -> None: - """删除指定插件输入会话及其联合索引。""" - with self._lock: - request = self._by_id.pop(request_id, None) - if request: - self._by_user_channel.pop( - self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id), - None, - ) - - def clear(self) -> None: - """清空活动和过期的插件输入会话。""" - with self._lock: - self._by_id.clear() - self._by_user_channel.clear() - self._expired_by_user_channel.clear() - - -plugin_input_interaction_manager = PluginInputInteractionManager() - - -@dataclass(frozen=True) -class AgentInteractionOption: - """ - Agent 交互选项。 - """ - - label: str - value: str - description: Optional[str] = None - - -@dataclass -class PendingAgentInteraction: - """ - 待处理的 Agent 客户端交互请求。 - """ - - request_id: str - session_id: str - user_id: str - channel: Optional[str] - source: Optional[str] - username: Optional[str] - title: Optional[str] - prompt: str - options: List[AgentInteractionOption] - created_at: datetime = field(default_factory=datetime.now) - - -class AgentInteractionManager: - """ - 管理 Agent 发起的客户端交互请求。 - """ - - _ttl = timedelta(hours=24) - - def __init__(self): - """初始化待处理的 Agent 交互请求表。""" - self._pending_interactions: Dict[str, PendingAgentInteraction] = {} - self._lock = Lock() - - def _cleanup_locked(self) -> None: - """在持锁状态下移除过期 Agent 交互。""" - expire_before = datetime.now() - self._ttl - expired_ids = [ - request_id - for request_id, request in self._pending_interactions.items() - if request.created_at < expire_before - ] - for request_id in expired_ids: - self._pending_interactions.pop(request_id, None) - - def create_request( - self, - session_id: str, - user_id: str, - channel: Optional[str], - source: Optional[str], - username: Optional[str], - title: Optional[str], - prompt: str, - options: List[AgentInteractionOption], - ) -> PendingAgentInteraction: - """ - 创建一条待用户确认的 Agent 交互请求。 - """ - with self._lock: - self._cleanup_locked() - request_id = uuid.uuid4().hex[:12] - while request_id in self._pending_interactions: - request_id = uuid.uuid4().hex[:12] - request = PendingAgentInteraction( - request_id=request_id, - session_id=session_id, - user_id=str(user_id), - channel=channel, - source=source, - username=username, - title=title, - prompt=prompt, - options=options, - ) - self._pending_interactions[request_id] = request - return request - - def resolve( - self, - request_id: str, - option_index: int, - user_id: Optional[str] = None, - ) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]: - """ - 消费一条 Agent 交互请求,并返回选中的选项。 - """ - with self._lock: - self._cleanup_locked() - request = self._pending_interactions.get(request_id) - if not request: - return None - if user_id is not None and str(request.user_id) != str(user_id): - return None - if option_index < 1 or option_index > len(request.options): - return None - option = request.options[option_index - 1] - self._pending_interactions.pop(request_id, None) - return request, option - - def clear(self) -> None: - """ - 清空所有 Agent 交互请求。 - """ - with self._lock: - self._pending_interactions.clear() - - -agent_interaction_manager = AgentInteractionManager() - - -@dataclass -class PendingSkillsInteraction: - """ - 记录一次 /skills 会话的上下文,便于按钮和文本回复共用同一状态。 - """ - - request_id: str - user_id: str - channel: Optional[MessageChannel] - source: Optional[str] - username: Optional[str] - view: str = "root" - local_page: int = 0 - market_page: int = 0 - market_query: str = "" - awaiting_input: Optional[str] = None - created_at: datetime = field(default_factory=datetime.now) - - -class SkillsInteractionManager: - """ - 管理用户当前的技能交互状态。 - - 每个用户同一时间只保留一个有效会话,避免旧按钮继续生效。 - """ - - _ttl = timedelta(hours=24) - - def __init__(self): - """初始化按请求和用户索引的技能交互会话表。""" - self._by_id: Dict[str, PendingSkillsInteraction] = {} - self._by_user: Dict[str, str] = {} - self._lock = Lock() - - def _cleanup_locked(self): - """ - 清理超时会话,避免按钮回调无限积累。 - """ - expire_before = datetime.now() - self._ttl - expired = [ - request_id - for request_id, request in self._by_id.items() - if request.created_at < expire_before - ] - for request_id in expired: - request = self._by_id.pop(request_id, None) - if request: - self._by_user.pop(str(request.user_id), None) - - def create_or_replace( - self, - user_id: Union[str, int], - channel: Optional[MessageChannel], - source: Optional[str], - username: Optional[str], - ) -> PendingSkillsInteraction: - """ - 为用户创建新会话,并替换掉旧的技能交互状态。 - """ - with self._lock: - self._cleanup_locked() - user_key = str(user_id) - old_request_id = self._by_user.get(user_key) - if old_request_id: - self._by_id.pop(old_request_id, None) - request_id = uuid.uuid4().hex[:12] - request = PendingSkillsInteraction( - request_id=request_id, - user_id=user_key, - channel=channel, - source=source, - username=username, - ) - self._by_id[request_id] = request - self._by_user[user_key] = request_id - return request - - def get_by_user( - self, user_id: Union[str, int] - ) -> Optional[PendingSkillsInteraction]: - """ - 按用户获取当前有效会话,供纯文本回复路由使用。 - """ - with self._lock: - self._cleanup_locked() - request_id = self._by_user.get(str(user_id)) - if not request_id: - return None - return self._by_id.get(request_id) - - def get_by_id( - self, request_id: str, user_id: Union[str, int] - ) -> Optional[PendingSkillsInteraction]: - """ - 按请求 ID 获取会话,并校验会话归属用户。 - """ - with self._lock: - self._cleanup_locked() - request = self._by_id.get(request_id) - if not request or str(request.user_id) != str(user_id): - return None - return request - - def remove(self, request_id: str) -> None: - """ - 主动结束会话,释放用户和请求 ID 的双向索引。 - """ - with self._lock: - request = self._by_id.pop(request_id, None) - if request: - self._by_user.pop(str(request.user_id), None) - - def clear(self): - """ - 清空所有会话,主要用于测试场景。 - """ - with self._lock: - self._by_id.clear() - self._by_user.clear() - - -skills_interaction_manager = SkillsInteractionManager() diff --git a/app/application/messaging/media.py b/app/application/messaging/media.py new file mode 100644 index 000000000..a60f7bf7a --- /dev/null +++ b/app/application/messaging/media.py @@ -0,0 +1,151 @@ +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from threading import Lock +from typing import Any, Dict, List, Optional, Union + +from app.domain.context import MediaInfo +from app.domain.meta.metabase import MetaBase +from app.schemas.types import MessageChannel + + +@dataclass +class PendingMediaInteraction: + """ + 记录一次搜索/下载/订阅交互的当前上下文。 + """ + + request_id: str + user_id: str + channel: Optional[MessageChannel] + source: Optional[str] + username: Optional[str] + action: str + keyword: str + phase: str = "media" + page: int = 0 + title: str = "" + meta: Optional[MetaBase] = None + current_media: Optional[MediaInfo] = None + items: List[Any] = field(default_factory=list) + download_dirs: List[Any] = field(default_factory=list) + pending_download_mode: Optional[str] = None + pending_download_context: Optional[Any] = None + pending_no_exists: Optional[Dict[Any, Any]] = None + pending_torrent_page: int = 0 + created_at: datetime = field(default_factory=datetime.now) + + +class MediaInteractionManager: + """ + 管理用户当前激活的媒体交互状态。 + + 每个用户只保留一个有效会话,避免旧按钮与新一轮搜索混用。 + """ + + _ttl = timedelta(hours=24) + + def __init__(self): + """初始化按请求和用户索引的媒体会话表。""" + self._by_id: Dict[str, PendingMediaInteraction] = {} + self._by_user: Dict[str, str] = {} + self._lock = Lock() + + def _cleanup_locked(self) -> None: + """ + 清理超时会话,避免内存中残留旧交互状态。 + """ + expire_before = datetime.now() - self._ttl + expired = [ + request_id + for request_id, request in self._by_id.items() + if request.created_at < expire_before + ] + for request_id in expired: + request = self._by_id.pop(request_id, None) + if request: + self._by_user.pop(str(request.user_id), None) + + def create_or_replace( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + username: Optional[str], + action: str, + keyword: str, + title: str = "", + meta: Optional[MetaBase] = None, + items: Optional[List[Any]] = None, + ) -> PendingMediaInteraction: + """ + 为用户创建新的交互状态,并替换旧会话。 + """ + with self._lock: + self._cleanup_locked() + user_key = str(user_id) + old_request_id = self._by_user.get(user_key) + if old_request_id: + self._by_id.pop(old_request_id, None) + + request = PendingMediaInteraction( + request_id=uuid.uuid4().hex[:12], + user_id=user_key, + channel=channel, + source=source, + username=username, + action=action, + keyword=keyword, + title=title, + meta=meta, + items=list(items or []), + ) + self._by_id[request.request_id] = request + self._by_user[user_key] = request.request_id + return request + + def get_by_user( + self, user_id: Union[str, int] + ) -> Optional[PendingMediaInteraction]: + """ + 按用户读取当前会话,供文本回复和旧按钮兼容使用。 + """ + with self._lock: + self._cleanup_locked() + request_id = self._by_user.get(str(user_id)) + if not request_id: + return None + return self._by_id.get(request_id) + + def get_by_id( + self, request_id: str, user_id: Union[str, int] + ) -> Optional[PendingMediaInteraction]: + """ + 按请求 ID 读取会话,并校验用户归属。 + """ + with self._lock: + self._cleanup_locked() + request = self._by_id.get(request_id) + if not request or str(request.user_id) != str(user_id): + return None + return request + + def remove(self, request_id: str) -> None: + """ + 主动结束一条会话。 + """ + with self._lock: + request = self._by_id.pop(request_id, None) + if request: + self._by_user.pop(str(request.user_id), None) + + def clear(self) -> None: + """ + 清空所有交互状态,主要用于测试。 + """ + with self._lock: + self._by_id.clear() + self._by_user.clear() + + +media_interaction_manager = MediaInteractionManager() diff --git a/app/application/messaging/plugin.py b/app/application/messaging/plugin.py new file mode 100644 index 000000000..758863a59 --- /dev/null +++ b/app/application/messaging/plugin.py @@ -0,0 +1,504 @@ +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from threading import Lock +from typing import Any, Dict, List, Optional, Tuple, Union + +from app.application.messaging.interaction import InteractionContext, MessageGateway +from app.runtime.events import EventManager +from app.schemas import Notification +from app.schemas.types import EventType, MessageChannel + + +@dataclass +class PendingPluginInputInteraction: + """ + 记录插件临时接管用户下一条文本输入的会话。 + """ + + request_id: str + user_id: str + plugin_id: str + channel: Optional[MessageChannel] + source: Optional[str] + username: Optional[str] + chat_id: Optional[str] = None + prompt_id: Optional[str] = None + payload: Optional[Any] = None + timeout_seconds: int = 120 + created_at: datetime = field(default_factory=datetime.now) + # Optional reply binding for channels that can report reply_to_message_id. + prompt_message_id: Optional[str] = None + + @property + def expires_at(self) -> datetime: + """返回输入会话的绝对过期时间。""" + return self.created_at + timedelta(seconds=max(1, self.timeout_seconds)) + + +class PluginInputInteractionManager: + """ + 管理插件输入会话。 + + 会话按用户和渠道绑定;同一用户在同一渠道只保留一个待输入会话。 + """ + + EXPIRED_GRACE_SECONDS = 300 + + def __init__(self): + """初始化活动输入会话、用户渠道索引和过期墓碑。""" + self._by_id: Dict[str, PendingPluginInputInteraction] = {} + self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {} + self._expired_by_user_channel: Dict[ + Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], + PendingPluginInputInteraction, + ] = {} + self._lock = Lock() + + @staticmethod + def _user_channel_source_key( + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str] = None, + chat_id: Optional[Union[str, int]] = None, + ) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]: + """归一化用户、渠道、来源和会话 ID 的联合索引键。""" + return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None + + @classmethod + def _keys_overlap( + cls, + left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], + right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], + ) -> bool: + """判断两个输入会话键是否会争用同一条用户回复。""" + left_user, left_channel, left_source, left_chat_id = left + right_user, right_channel, right_source, right_chat_id = right + if left_user != right_user: + return False + if left_chat_id and right_chat_id and left_chat_id != right_chat_id: + return False + if (left_channel is None and left_source is None) or (right_channel is None and right_source is None): + return left_channel == right_channel and left_source == right_source + channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None + source_overlap = left_source == right_source or left_source is None or right_source is None + return channel_overlap and source_overlap + + def _cleanup_locked(self) -> None: + """在持锁状态下淘汰过期会话并维护短期过期墓碑。""" + now = datetime.now() + expired_tombstones = [ + key + for key, request in self._expired_by_user_channel.items() + if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now + ] + for key in expired_tombstones: + self._expired_by_user_channel.pop(key, None) + + expired = [ + request_id + for request_id, request in self._by_id.items() + if request.expires_at < now + ] + for request_id in expired: + request = self._by_id.pop(request_id, None) + if request: + key = self._user_channel_source_key( + request.user_id, + request.channel, + request.source, + request.chat_id, + ) + self._by_user_channel.pop(key, None) + self._expired_by_user_channel[key] = request + + def create_or_replace( + self, + user_id: Union[str, int], + plugin_id: str, + channel: Optional[MessageChannel], + source: Optional[str], + username: Optional[str], + chat_id: Optional[Union[str, int]] = None, + prompt_id: Optional[str] = None, + timeout_seconds: int = 120, + payload: Optional[Any] = None, + *, + prompt_message_id: Optional[Union[str, int]] = None, + ) -> PendingPluginInputInteraction: + """创建插件输入会话并替换键范围重叠的旧会话。""" + with self._lock: + self._cleanup_locked() + key = self._user_channel_source_key(user_id, channel, source, chat_id) + old_request_ids = [ + request_id + for stored_key, request_id in self._by_user_channel.items() + if self._keys_overlap(stored_key, key) + ] + for old_request_id in old_request_ids: + self._by_id.pop(old_request_id, None) + self._by_user_channel = { + stored_key: request_id + for stored_key, request_id in self._by_user_channel.items() + if request_id not in old_request_ids + } + self._expired_by_user_channel = { + stored_key: request + for stored_key, request in self._expired_by_user_channel.items() + if not self._keys_overlap(stored_key, key) + } + + normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None + normalized_prompt_message_id = ( + str(prompt_message_id) + if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "") + else None + ) + + request = PendingPluginInputInteraction( + request_id=uuid.uuid4().hex[:12], + user_id=str(user_id), + plugin_id=plugin_id, + channel=channel, + source=source, + username=username, + chat_id=normalized_chat_id, + prompt_id=prompt_id, + prompt_message_id=normalized_prompt_message_id, + timeout_seconds=timeout_seconds, + payload=payload, + ) + self._by_id[request.request_id] = request + self._by_user_channel[key] = request.request_id + return request + + def get_by_user( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel] = None, + source: Optional[str] = None, + chat_id: Optional[Union[str, int]] = None, + ) -> Optional[PendingPluginInputInteraction]: + """按用户和渠道上下文查询活动输入会话。""" + with self._lock: + self._cleanup_locked() + request_id = self._find_request_id_locked(user_id, channel, source, chat_id) + if request_id: + return self._by_id.get(request_id) + return None + + def pop_by_user( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel] = None, + source: Optional[str] = None, + chat_id: Optional[Union[str, int]] = None, + ) -> Optional[PendingPluginInputInteraction]: + """取出并删除活动或刚过期的输入会话。""" + with self._lock: + self._cleanup_locked() + key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) + if request_id: + self._by_user_channel.pop(key, None) + return self._by_id.pop(request_id, None) + expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id) + if expired_key: + self._expired_by_user_channel.pop(expired_key, None) + return request + + def consume_by_user( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel] = None, + source: Optional[str] = None, + chat_id: Optional[Union[str, int]] = None, + *, + reply_to_message_id: Optional[Union[str, int]] = None, + bypass_reply_check: bool = False, + ) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]: + """消费匹配回复的输入会话,并返回 active 或 expired 状态。""" + with self._lock: + key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) + + if request_id: + request = self._by_id.get(request_id) + if not request: + self._by_user_channel.pop(key, None) + elif request.expires_at < datetime.now(): + self._by_user_channel.pop(key, None) + self._by_id.pop(request_id, None) + if request.prompt_message_id: + return None, None + return request, "expired" + elif not self._reply_matches_prompt( + request, + chat_id, + reply_to_message_id, + ignore_reply_to_message_id=bypass_reply_check, + ): + return None, None + else: + self._by_user_channel.pop(key, None) + self._by_id.pop(request_id, None) + return request, "active" + self._cleanup_locked() + key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id) + if request: + self._expired_by_user_channel.pop(key, None) + if request.prompt_message_id: + return None, None + return request, "expired" + self._cleanup_locked() + return None, None + + @staticmethod + def _reply_matches_prompt( + request: PendingPluginInputInteraction, + chat_id: Optional[Union[str, int]], + reply_to_message_id: Optional[Union[str, int]], + *, + ignore_reply_to_message_id: bool = False, + ) -> bool: + """校验消息回复关系是否绑定到原始提示。""" + if not request.prompt_message_id: + return True + if not request.chat_id or chat_id in (None, ""): + return False + if str(chat_id) != str(request.chat_id): + return False + if ignore_reply_to_message_id: + return True + if reply_to_message_id in (None, ""): + return False + return str(reply_to_message_id) == str(request.prompt_message_id) + + def _find_request_id_locked( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + chat_id: Optional[Union[str, int]] = None, + ) -> Optional[str]: + """在持锁状态下查找活动请求 ID。""" + _, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id) + return request_id + + def _find_key_and_request_id_locked( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + chat_id: Optional[Union[str, int]] = None, + ) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]: + """返回首个候选键及其活动请求 ID。""" + for key in self._candidate_keys(user_id, channel, source, chat_id): + request_id = self._by_user_channel.get(key) + if request_id: + return key, request_id + return None, None + + def _find_expired_key_and_request_locked( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + chat_id: Optional[Union[str, int]] = None, + ) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], + Optional[PendingPluginInputInteraction]]: + """返回仍在宽限期内的过期会话及其索引键。""" + now = datetime.now() + for key in self._candidate_keys(user_id, channel, source, chat_id): + request = self._expired_by_user_channel.get(key) + if not request: + continue + if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now: + self._expired_by_user_channel.pop(key, None) + continue + return key, request + return None, None + + def _candidate_keys( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + chat_id: Optional[Union[str, int]] = None, + ) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]: + """按精确到宽松顺序生成输入会话候选键。""" + chat_key = str(chat_id) if chat_id not in (None, "") else None + candidates = [ + self._user_channel_source_key(user_id, channel, source, chat_key), + ] + if source is not None: + candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key)) + if channel is not None and source is not None: + candidates.append(self._user_channel_source_key(user_id, None, source, chat_key)) + if channel is None and source is None: + wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key) + candidates.append(wildcard_key) + if chat_key is not None: + candidates.append(self._user_channel_source_key(user_id, channel, source, None)) + if source is not None: + candidates.append(self._user_channel_source_key(user_id, channel, None, None)) + if channel is not None and source is not None: + candidates.append(self._user_channel_source_key(user_id, None, source, None)) + if channel is None and source is None: + candidates.append(self._user_channel_source_key(user_id, None, None, None)) + return candidates + + def remove(self, request_id: str) -> None: + """删除指定插件输入会话及其联合索引。""" + with self._lock: + request = self._by_id.pop(request_id, None) + if request: + self._by_user_channel.pop( + self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id), + None, + ) + + def clear(self) -> None: + """清空活动和过期的插件输入会话。""" + with self._lock: + self._by_id.clear() + self._by_user_channel.clear() + self._expired_by_user_channel.clear() + + +plugin_input_interaction_manager = PluginInputInteractionManager() + + +class PluginInputInteractionHandler: + """消费插件申请接管的下一条用户文本输入。""" + + def __init__(self, messenger: MessageGateway): + """保存消息投递接口。""" + self._messenger = messenger + + def handle_text( + self, + *, + context: InteractionContext, + text: str, + reply_to_message_id: Optional[Union[str, int]] = None, + images=None, + audio_refs=None, + files=None, + has_audio_input: bool = False, + ) -> bool: + """消费插件输入会话,并派发 MessageAction 事件。""" + if not text or not text.strip() or images or audio_refs or files or has_audio_input: + return False + if text.startswith("CALLBACK:"): + return False + + channel = context.channel + source = context.source + userid = context.user_id + username = context.username + original_chat_id = context.original_chat_id + + is_cancel_text = text.strip().lower() in {"取消", "退出", "q", "quit", "exit"} + request, status = plugin_input_interaction_manager.consume_by_user( + userid, + channel, + source, + original_chat_id, + reply_to_message_id=reply_to_message_id, + bypass_reply_check=is_cancel_text, + ) + if not request: + return False + + if status == "expired": + # 调用时解析单例,避免模块级绑定在单例注册表被重置后与宿主脱钩 + EventManager().send_event( + EventType.MessageAction, + { + "plugin_id": request.plugin_id, + "__mp_target_plugin_id": request.plugin_id, + "text": f"plugin_input_expired|{request.request_id}", + "userid": userid, + "channel": channel, + "source": source, + "username": username, + "chat_id": original_chat_id, + "reply_to_message_id": reply_to_message_id, + "prompt_id": request.prompt_id, + "input_session_id": request.request_id, + "expired": True, + "payload": request.payload, + }, + ) + self._messenger.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="插件输入已超时,请重新发起操作。", + save_history=False, + ) + ) + return not text.strip().startswith("/") + + if is_cancel_text: + EventManager().send_event( + EventType.MessageAction, + { + "plugin_id": request.plugin_id, + "__mp_target_plugin_id": request.plugin_id, + "text": f"plugin_input_cancel|{request.request_id}", + "userid": userid, + "channel": channel, + "source": source, + "username": username, + "chat_id": original_chat_id, + "reply_to_message_id": reply_to_message_id, + "prompt_id": request.prompt_id, + "input_session_id": request.request_id, + "cancelled": True, + "payload": request.payload, + }, + ) + self._messenger.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="已取消插件输入", + save_history=False, + ) + ) + return True + + EventManager().send_event( + EventType.MessageAction, + { + "plugin_id": request.plugin_id, + "__mp_target_plugin_id": request.plugin_id, + "text": f"plugin_input|{request.request_id}", + "input_text": text, + "userid": userid, + "channel": channel, + "source": source, + "username": username, + "chat_id": original_chat_id, + "reply_to_message_id": reply_to_message_id, + "prompt_id": request.prompt_id, + "input_session_id": request.request_id, + "payload": request.payload, + }, + ) + return True + + @staticmethod + def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]: + """解析插件按钮回调,格式错误时返回 None。""" + if not callback_data.startswith("[PLUGIN]"): + return None + # 用 partition 避免缺少分隔符的回调抛异常 + plugin_id, separator, content = callback_data.partition("|") + if not separator: + return None + return plugin_id.replace("[PLUGIN]", "", 1), content diff --git a/app/application/messaging/router.py b/app/application/messaging/router.py new file mode 100644 index 000000000..080fd87d5 --- /dev/null +++ b/app/application/messaging/router.py @@ -0,0 +1,121 @@ +""" +交互路由层:统一选择活动文本会话,并按固定顺序派发按钮回调。 + +文本会话候选覆盖 Site、Subscribe、Skill、Media 四类, +按会话创建时间选择最近激活的一条,避免旧会话抢占新会话的输入。 +""" + +from dataclasses import dataclass +from typing import Any, Callable, Optional, Sequence, Union + +from app.application.messaging.interaction import ( + InteractionContext, + InteractionDispatch, +) +from app.application.messaging.media import media_interaction_manager +from app.application.messaging.site import site_interaction_manager +from app.application.messaging.skill import skill_interaction_manager +from app.application.messaging.subscribe import subscribe_interaction_manager + + +@dataclass(frozen=True, slots=True) +class SessionRoute: + """描述一种可继续接收文本的交互会话。""" + + # 会话名称,与对应 Slash 命令一致 + name: str + # 返回用户当前待处理会话对象(含 created_at),无会话返回 None + get_pending: Callable[[Union[str, int]], Optional[Any]] + # 将一条文本派发给该会话,返回是否已消费 + handle_text: Callable[[InteractionContext, str], bool] + + +@dataclass(frozen=True, slots=True) +class CallbackRoute: + """描述一种按钮回调的匹配和处理方式。""" + + # 路由名称,用于日志和排查 + name: str + # 按回调内容判断是否归本路由处理 + matches: Callable[[str], bool] + # 执行回调处理并返回派发结果 + dispatch: Callable[[str, InteractionContext], InteractionDispatch] + + +class InteractionRouter: + """统一选择活动文本会话并按顺序派发按钮回调。""" + + def __init__( + self, + session_routes: Sequence[SessionRoute], + callback_routes: Sequence[CallbackRoute], + ): + """按注册顺序保存会话路由和回调路由,回调顺序即优先级。""" + self._session_routes = list(session_routes) + self._callback_routes = list(callback_routes) + + def latest_session(self, user_id: Union[str, int]) -> Optional[SessionRoute]: + """返回最近创建的待处理文本会话路由,无会话返回 None。""" + best_route: Optional[SessionRoute] = None + best_created_at = None + for route in self._session_routes: + pending = route.get_pending(user_id) + if pending is None: + continue + created_at = getattr(pending, "created_at", None) + # 缺少时间戳的会话视为最早,保证有时间戳的新会话优先 + if best_route is None or ( + created_at is not None + and (best_created_at is None or created_at > best_created_at) + ): + best_route = route + best_created_at = created_at + return best_route + + def has_pending(self, user_id: Union[str, int]) -> bool: + """判断用户是否存在任意待处理文本会话。""" + return any( + route.get_pending(user_id) is not None + for route in self._session_routes + ) + + def dispatch_active_text( + self, + context: InteractionContext, + text: str, + ) -> bool: + """把文本派发给最近活动的会话,返回是否被消费。""" + route = self.latest_session(context.user_id) + if route is None: + return False + return route.handle_text(context, text) + + def dispatch_callback( + self, + context: InteractionContext, + callback_data: str, + ) -> InteractionDispatch: + """按注册顺序匹配并派发按钮回调,均不匹配时返回未处理。 + + 匹配到的路由未消费回调(handled=False)时继续尝试后续路由, + 与既有顺序式派发的行为保持一致。 + """ + for route in self._callback_routes: + if route.matches(callback_data): + result = route.dispatch(callback_data, context) + if result.handled: + return result + return InteractionDispatch(handled=False) + + +def has_pending_interaction(user_id: Union[str, int]) -> bool: + """供 WebAgent 判断用户是否处于传统交互会话。""" + return any( + manager.get_by_user(user_id) is not None + for manager in ( + site_interaction_manager, + subscribe_interaction_manager, + skill_interaction_manager, + media_interaction_manager, + ) + ) diff --git a/app/application/messaging/site.py b/app/application/messaging/site.py new file mode 100644 index 000000000..fb9836169 --- /dev/null +++ b/app/application/messaging/site.py @@ -0,0 +1,587 @@ +import re +from typing import Callable, List, Optional, Tuple, Union + +from app.db.models.site import Site +from app.db.oper.site import SiteOper +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 import Notification +from app.schemas.types import MessageChannel + + +site_interaction_manager = SlashInteractionManager() + + +class SiteInteractionHandler: + """ + 管理 /sites 交互会话、输入解析和站点列表渲染。 + """ + + _button_page_size = 6 + _text_page_size = 10 + + def __init__( + self, + messenger: MessageGateway, + cookie_updater: Callable[..., Tuple[bool, str]], + ): + """ + 注入消息投递接口和站点 Cookie 更新动作。 + """ + self._messenger = messenger + self._cookie_updater = cookie_updater + + def remote_list( + self, + arg_str: str = "", + channel: MessageChannel = 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: MessageChannel, + 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( + Notification( + 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: MessageChannel, + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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: MessageChannel, + 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 = SiteOper().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[Site], channel: Optional[MessageChannel] + ) -> 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,请输入: [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 [2fa]`、`启用 `、`禁用 `、" + "`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" + + siteoper = SiteOper() + changed = [] + missing = [] + for site_id in site_ids: + site = siteoper.get(site_id) + if not site: + missing.append(str(site_id)) + continue + siteoper.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 [2fa_code/secret]", + ) + + site_id = int(args[0]) + site_info = SiteOper().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 更新成功" diff --git a/app/chain/skills.py b/app/application/messaging/skill.py similarity index 86% rename from app/chain/skills.py rename to app/application/messaging/skill.py index 8810b282f..8a9d0fbe6 100644 --- a/app/chain/skills.py +++ b/app/application/messaging/skill.py @@ -1,19 +1,147 @@ import re -from typing import List, Optional, Tuple, Union +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from threading import Lock +from typing import Dict, List, Optional, Tuple, Union -from app.chain import ChainBase +from app.agent.skills.registry import SkillHelper, SkillInfo from app.application.messaging.interaction import ( + MessageGateway, build_navigation_buttons, page_items, supports_interaction_buttons, - update_or_post_message, skills_interaction_manager, PendingSkillsInteraction, + update_or_post_message, ) -from app.agent.skills.registry import SkillHelper, SkillInfo from app.schemas import Notification from app.schemas.types import MessageChannel -class SkillsChain(ChainBase): +@dataclass +class PendingSkillInteraction: + """ + 记录一次 /skills 会话的上下文,便于按钮和文本回复共用同一状态。 + """ + + request_id: str + user_id: str + channel: Optional[MessageChannel] + source: Optional[str] + username: Optional[str] + view: str = "root" + local_page: int = 0 + market_page: int = 0 + market_query: str = "" + awaiting_input: Optional[str] = None + created_at: datetime = field(default_factory=datetime.now) + + +class SkillInteractionManager: + """ + 管理用户当前的技能交互状态。 + + 每个用户同一时间只保留一个有效会话,避免旧按钮继续生效。 + """ + + _ttl = timedelta(hours=24) + + def __init__(self): + """初始化按请求和用户索引的技能交互会话表。""" + self._by_id: Dict[str, PendingSkillInteraction] = {} + self._by_user: Dict[str, str] = {} + self._lock = Lock() + + def _cleanup_locked(self): + """ + 清理超时会话,避免按钮回调无限积累。 + """ + expire_before = datetime.now() - self._ttl + expired = [ + request_id + for request_id, request in self._by_id.items() + if request.created_at < expire_before + ] + for request_id in expired: + request = self._by_id.pop(request_id, None) + if request: + self._by_user.pop(str(request.user_id), None) + + def create_or_replace( + self, + user_id: Union[str, int], + channel: Optional[MessageChannel], + source: Optional[str], + username: Optional[str], + ) -> PendingSkillInteraction: + """ + 为用户创建新会话,并替换掉旧的技能交互状态。 + """ + with self._lock: + self._cleanup_locked() + user_key = str(user_id) + old_request_id = self._by_user.get(user_key) + if old_request_id: + self._by_id.pop(old_request_id, None) + request_id = uuid.uuid4().hex[:12] + request = PendingSkillInteraction( + request_id=request_id, + user_id=user_key, + channel=channel, + source=source, + username=username, + ) + self._by_id[request_id] = request + self._by_user[user_key] = request_id + return request + + def get_by_user( + self, user_id: Union[str, int] + ) -> Optional[PendingSkillInteraction]: + """ + 按用户获取当前有效会话,供纯文本回复路由使用。 + """ + with self._lock: + self._cleanup_locked() + request_id = self._by_user.get(str(user_id)) + if not request_id: + return None + return self._by_id.get(request_id) + + def get_by_id( + self, request_id: str, user_id: Union[str, int] + ) -> Optional[PendingSkillInteraction]: + """ + 按请求 ID 获取会话,并校验会话归属用户。 + """ + with self._lock: + self._cleanup_locked() + request = self._by_id.get(request_id) + if not request or str(request.user_id) != str(user_id): + return None + return request + + def remove(self, request_id: str) -> None: + """ + 主动结束会话,释放用户和请求 ID 的双向索引。 + """ + with self._lock: + request = self._by_id.pop(request_id, None) + if request: + self._by_user.pop(str(request.user_id), None) + + def clear(self): + """ + 清空所有会话,主要用于测试场景。 + """ + with self._lock: + self._by_id.clear() + self._by_user.clear() + + +skill_interaction_manager = SkillInteractionManager() + + +class SkillInteractionHandler: """ 处理 /skills 指令、按钮回调和文本式技能管理交互。 """ @@ -21,9 +149,14 @@ class SkillsChain(ChainBase): _button_page_size = 6 _text_page_size = 8 - def __init__(self): - super().__init__() - self.skillhelper = SkillHelper() + def __init__( + self, + messenger: MessageGateway, + skill_helper: Optional[SkillHelper] = None, + ): + """注入消息接口和技能管理能力。""" + self._messenger = messenger + self.skillhelper = skill_helper or SkillHelper() def remote_manage( self, @@ -35,7 +168,7 @@ class SkillsChain(ChainBase): """ /skills 入口。创建新会话并渲染首屏菜单。 """ - request = skills_interaction_manager.create_or_replace( + request = skill_interaction_manager.create_or_replace( user_id=userid, channel=channel, source=source, @@ -95,9 +228,9 @@ class SkillsChain(ChainBase): return False request_id, action, index = parsed - request = skills_interaction_manager.get_by_id(request_id, userid) + request = skill_interaction_manager.get_by_id(request_id, userid) if not request: - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -113,7 +246,7 @@ class SkillsChain(ChainBase): request.username = username if action == "close": - skills_interaction_manager.remove(request.request_id) + skill_interaction_manager.remove(request.request_id) self._update_or_post_message( channel=channel, source=source, @@ -177,7 +310,7 @@ class SkillsChain(ChainBase): request.awaiting_input = None success, message = self._install_market_skill(request, index) if success: - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -187,7 +320,7 @@ class SkillsChain(ChainBase): ) ) else: - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -199,7 +332,7 @@ class SkillsChain(ChainBase): elif action == "remove" and index: request.awaiting_input = None success, message = self._remove_local_skill(request, index) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -215,7 +348,7 @@ class SkillsChain(ChainBase): request.view = "sources" request.awaiting_input = None success, message = self._remove_market_source(index) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -247,7 +380,7 @@ class SkillsChain(ChainBase): """ 处理不支持按钮渠道上的文本指令,也兼容用户直接回复文字操作。 """ - request = skills_interaction_manager.get_by_user(userid) + request = skill_interaction_manager.get_by_user(userid) if not request: return False @@ -258,8 +391,8 @@ class SkillsChain(ChainBase): normalized = (text or "").strip() lowered = normalized.lower() if lowered in {"退出", "关闭", "q", "quit", "exit"}: - skills_interaction_manager.remove(request.request_id) - self.post_message( + skill_interaction_manager.remove(request.request_id) + self._messenger.post_message( Notification( channel=channel, source=source, @@ -294,7 +427,7 @@ class SkillsChain(ChainBase): request.view = "sources" request.awaiting_input = None _, message = self.skillhelper.add_custom_market_source(add_source) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -318,7 +451,7 @@ class SkillsChain(ChainBase): _, message = self._remove_market_source( page_index=int(remove_source_match.group(1)) ) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -391,7 +524,7 @@ class SkillsChain(ChainBase): self._extract_market_search_query(normalized), ) else: - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -416,7 +549,7 @@ class SkillsChain(ChainBase): else: _, message = self.skillhelper.add_custom_market_source(normalized) request.awaiting_input = None - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -481,7 +614,7 @@ class SkillsChain(ChainBase): request=request, page_index=int(install_match.group(1)), ) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -504,7 +637,7 @@ class SkillsChain(ChainBase): request=request, page_index=int(remove_match.group(1)), ) - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -522,7 +655,7 @@ class SkillsChain(ChainBase): ) return True - self.post_message( + self._messenger.post_message( Notification( channel=channel, source=source, @@ -535,7 +668,7 @@ class SkillsChain(ChainBase): def _install_market_skill( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, page_index: int, ) -> Tuple[bool, str]: """ @@ -554,7 +687,7 @@ class SkillsChain(ChainBase): def _remove_local_skill( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, page_index: int, ) -> Tuple[bool, str]: """ @@ -588,7 +721,7 @@ class SkillsChain(ChainBase): def _render_interaction( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, channel: MessageChannel, source: Optional[str], userid: Union[str, int], @@ -633,7 +766,7 @@ class SkillsChain(ChainBase): def _build_root_view( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, force_market_refresh: bool = False, ) -> Tuple[str, str, Optional[List[List[dict]]]]: """ @@ -684,7 +817,7 @@ class SkillsChain(ChainBase): def _build_installed_view( self, - request: PendingSkillsInteraction + request: PendingSkillInteraction ) -> Tuple[str, str, Optional[List[List[dict]]]]: """ 构建已安装技能视图,列出来源和可删除状态。 @@ -744,7 +877,7 @@ class SkillsChain(ChainBase): def _build_market_view( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, force_market_refresh: bool = False, ) -> Tuple[str, str, Optional[List[List[dict]]]]: """ @@ -845,7 +978,7 @@ class SkillsChain(ChainBase): def _build_sources_view( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, ) -> Tuple[str, str, Optional[List[List[dict]]]]: """ 构建技能源管理视图,提供自定义 GitHub 源的增删入口。 @@ -954,7 +1087,7 @@ class SkillsChain(ChainBase): @staticmethod def _navigation_buttons( - request: PendingSkillsInteraction, + request: PendingSkillInteraction, page: int, total_pages: int, ) -> List[List[dict]]: @@ -984,7 +1117,7 @@ class SkillsChain(ChainBase): 优先编辑原消息,编辑失败时再回退为发送新消息。 """ update_or_post_message( - chain=self, + chain=self._messenger, channel=channel, source=source, userid=userid, @@ -1011,7 +1144,7 @@ class SkillsChain(ChainBase): def _get_market_skills( self, - request: PendingSkillsInteraction, + request: PendingSkillInteraction, force_market_refresh: bool = False, ) -> List[SkillInfo]: """ @@ -1057,7 +1190,7 @@ class SkillsChain(ChainBase): @staticmethod def _apply_market_search( - request: PendingSkillsInteraction, + request: PendingSkillInteraction, query: str, ) -> None: """ @@ -1069,7 +1202,7 @@ class SkillsChain(ChainBase): request.awaiting_input = None @staticmethod - def _clear_market_search(request: PendingSkillsInteraction) -> None: + def _clear_market_search(request: PendingSkillInteraction) -> None: """ 清除当前市场搜索状态,恢复全量市场列表。 """ diff --git a/app/application/messaging/subscribe.py b/app/application/messaging/subscribe.py new file mode 100644 index 000000000..12e3a8f33 --- /dev/null +++ b/app/application/messaging/subscribe.py @@ -0,0 +1,725 @@ +import re +from typing import List, Optional, Protocol, Tuple, Union + +from app.adapters.external.server import MoviePilotServerHelper +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.db.models.subscribe import Subscribe +from app.db.oper.subscribe import SubscribeOper +from app.schemas import Notification +from app.schemas.types import MessageChannel, MediaType + + +subscribe_interaction_manager = SlashInteractionManager() + + +class SubscribeInteractionActions(Protocol): + """ + 声明订阅交互需要调用的业务动作。 + """ + + def refresh(self): + """执行订阅刷新。""" + ... + + def check(self): + """执行订阅元数据检查。""" + ... + + def search(self, **kwargs): + """执行订阅搜索。""" + ... + + +class SubscribeInteractionHandler: + """ + 管理 /subscribes 交互会话、按钮、文本输入和列表视图。 + """ + + _button_page_size = 6 + _text_page_size = 10 + + def __init__( + self, + messenger: MessageGateway, + actions: SubscribeInteractionActions, + ): + """ + 注入消息投递接口和订阅业务动作。 + """ + self._messenger = messenger + self._actions = actions + + def remote_list( + self, + arg_str: str = "", + channel: MessageChannel = 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: MessageChannel, + 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( + Notification( + 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: MessageChannel, + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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( + Notification( + 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: MessageChannel, + 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 = SubscribeOper().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[Subscribe], channel: Optional[MessageChannel] + ) -> 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: Subscribe) -> 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 ( + "可输入:`搜索 `、`删除 `、`刷新`、`刷新元数据`、`n`、`p`、`退出`。" + ) + + def _run_refresh_action( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 执行订阅刷新。 + """ + self._messenger.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="开始刷新订阅...", + ) + ) + self._actions.refresh() + self._messenger.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="订阅刷新执行完成", + ) + ) + + def _run_metadata_refresh_action( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 执行订阅元数据刷新。 + """ + self._messenger.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="开始刷新订阅元数据...", + ) + ) + self._actions.check() + self._messenger.post_message( + Notification( + 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: MessageChannel, + 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( + Notification( + 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" + + subscribeoper = SubscribeOper() + missing = [] + searched = [] + for subscribe_id in subscribe_ids: + subscribe = subscribeoper.get(subscribe_id) + if not subscribe: + missing.append(str(subscribe_id)) + continue + self._messenger.post_message( + Notification( + 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" + + subscribeoper = SubscribeOper() + deleted = [] + missing = [] + for subscribe_id in subscribe_ids: + subscribe = subscribeoper.get(subscribe_id) + if not subscribe: + missing.append(str(subscribe_id)) + continue + deleted.append(subscribe.name) + subscribeoper.delete(subscribe_id) + MoviePilotServerHelper.sub_done_async( + { + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, + } + ) + + 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 diff --git a/app/chain/interaction.py b/app/chain/interaction.py new file mode 100644 index 000000000..1ea571241 --- /dev/null +++ b/app/chain/interaction.py @@ -0,0 +1,1567 @@ +import math +import re +from typing import Any, Dict, List, Optional, Tuple, Union + +from app.chain import ChainBase +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain +from app.application.directory import DirectoryHelper +from app.application.messaging.media import ( + PendingMediaInteraction, + media_interaction_manager, +) +from app.application.torrent import TorrentHelper +from app.db.oper.user import UserOper +from app.domain import episode as episode_rules +from app.domain import title as title_rules +from app.domain.context import Context, MediaInfo +from app.domain.meta.metabase import MetaBase +from app.foundation import url as url_tools +from app.runtime.config import settings +from app.runtime.log import logger +from app.schemas import DownloadDirectory, FileURI, NotExistMediaInfo, Notification +from app.schemas.media import build_media_key, resolve_media_identity +from app.schemas.message import ChannelCapabilityManager +from app.schemas.system import TransferDirectoryConf +from app.schemas.types import MediaType, MessageChannel + + +class MediaInteractionChain(ChainBase): + """ + 处理媒体搜索、订阅、资源选择和翻页等交互流程。 + """ + + _button_page_size = 8 + _text_page_size = 8 + _auto_download_dir_name = "自动匹配目录" + + @staticmethod + def has_pending_interaction(user_id: Union[str, int]) -> bool: + """ + 判断用户当前是否存在未结束的媒体交互。 + """ + return media_interaction_manager.get_by_user(user_id) is not None + + @staticmethod + def _get_noexits_info( + meta: MetaBase, mediainfo: MediaInfo + ) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]: + """ + 构造媒体缺失集信息,用于全量重搜或自动下载补全集数。 + """ + if mediainfo.type == MediaType.TV: + if not mediainfo.seasons: + mediainfo = MediaChain().recognize_media( + mtype=mediainfo.type, + media_source=resolve_media_identity(media=mediainfo)[0], + media_id=resolve_media_identity(media=mediainfo)[1], + cache=False, + ) + if not mediainfo: + logger.warn("媒体信息识别失败,无法补充季集信息") + return {} + if not mediainfo.seasons: + logger.warn( + "媒体信息中没有季集信息,标题:%s,tmdbid:%s,doubanid:%s", + mediainfo.title, + mediainfo.tmdb_id, + mediainfo.douban_id, + ) + return {} + + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) + no_exists = {mediakey: {}} + if meta.begin_season is not None: + episodes = mediainfo.seasons.get(meta.begin_season) + if not episodes: + return {} + no_exists[mediakey][meta.begin_season] = NotExistMediaInfo( + season=meta.begin_season, + episodes=[], + total_episode=len(episodes), + start_episode=episodes[0], + ) + else: + for sea, eps in mediainfo.seasons.items(): + if not eps: + continue + no_exists[mediakey][sea] = NotExistMediaInfo( + season=sea, + episodes=[], + total_episode=len(eps), + start_episode=eps[0], + ) + return no_exists + return {} + + @staticmethod + def parse_callback( + callback_data: str, + ) -> Optional[Tuple[Optional[str], str, Optional[int]]]: + """ + 解析新旧两种媒体交互按钮格式。 + """ + if callback_data.startswith("media:"): + parts = callback_data.split(":") + if len(parts) < 3: + return None + request_id = parts[1] + action = parts[2] + index = None + if len(parts) >= 4 and parts[3].isdigit(): + index = int(parts[3]) + return request_id, action, index + + match = re.match(r"^(select|download)_(\d+)$", callback_data) + if match: + return None, match.group(1), int(match.group(2)) + if callback_data == "page_p": + return None, "page-prev", None + if callback_data == "page_n": + return None, "page-next", None + return None + + def handle_callback_interaction( + self, + callback_data: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> bool: + """ + 处理按钮回调,并将当前视图刷新到原消息上。 + """ + parsed = self.parse_callback(callback_data) + if not parsed: + return False + + request_id, action, index = parsed + if request_id: + request = media_interaction_manager.get_by_id(request_id, userid) + else: + request = media_interaction_manager.get_by_user(userid) + + if not request: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="交互已失效,请重新搜索或订阅", + save_history=False, + ) + ) + return True + + request.channel = channel + request.source = source + request.username = username + + if action == "page-prev": + if request.page <= 0: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="已经是第一页了!", + ) + return True + request.page -= 1 + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + return True + + if action == "page-next": + if not self._has_next_page(request): + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="已经是最后一页了!", + ) + return True + request.page += 1 + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + return True + + if action == "select": + self._handle_media_selection( + request=request, + page_index=index, + 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._handle_torrent_selection( + request=request, + page_index=index, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + if action == "download-dir": + self._handle_download_dir_selection( + request=request, + page_index=index, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + return False + + def handle_text_interaction( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + text: str, + ) -> bool: + """ + 处理文本式交互。 + + 有会话时优先处理数字选择和翻页;无会话时负责识别搜索/订阅类入口。 + """ + request = media_interaction_manager.get_by_user(userid) + normalized = (text or "").strip() + lowered = normalized.lower() + + if request and lowered in {"退出", "关闭", "q", "quit", "exit"}: + media_interaction_manager.remove(request.request_id) + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="媒体交互已结束", + save_history=False, + ) + ) + return True + + if normalized.isdigit(): + if not request: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + request.channel = channel + request.source = source + request.username = username + index = int(normalized) + if request.phase == "download-dir": + self._handle_download_dir_selection( + request=request, + page_index=index, + channel=channel, + source=source, + userid=userid, + username=username, + ) + elif request.phase == "torrent": + self._handle_torrent_selection( + request=request, + page_index=index, + channel=channel, + source=source, + userid=userid, + username=username, + ) + else: + self._handle_media_selection( + request=request, + page_index=index, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + if lowered in {"p", "prev", "上一页"}: + if not request: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + if request.page <= 0: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="已经是第一页了!", + ) + return True + request.page -= 1 + request.channel = channel + request.source = source + request.username = username + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + ) + return True + + if lowered in {"n", "next", "下一页"}: + if not request: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + if not self._has_next_page(request): + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="已经是最后一页了!", + ) + return True + request.page += 1 + request.channel = channel + request.source = source + request.username = username + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + ) + return True + + action, content = self._resolve_action(normalized) + if not action: + return False + + self._start_media_interaction( + action=action, + content=content, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + @staticmethod + def _resolve_action(text: str) -> Tuple[Optional[str], str]: + """ + 将用户输入归类为搜索、订阅或普通聊天。 + """ + if text.startswith("订阅"): + return "Subscribe", re.sub(r"订阅[::\s]*", "", text) + if text.startswith("洗版"): + return "ReSubscribe", re.sub(r"洗版[::\s]*", "", text) + if text.startswith("搜索") or text.startswith("下载"): + return "ReSearch", re.sub(r"(搜索|下载)[::\s]*", "", text) + if url_tools.is_link(text): + return None, text + if not title_rules.is_media_title_like(text): + return None, text + return "Search", text + + def _start_media_interaction( + self, + action: str, + content: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 根据用户输入搜索媒体,并进入媒体选择阶段。 + """ + meta, medias = MediaChain().search(content) + if not meta.name: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="无法识别输入内容!", + ) + return + if not medias: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"{meta.name} 没有找到对应的媒体信息!", + save_history=False, + ) + ) + return + + logger.info("搜索到 %s 条相关媒体信息", len(medias)) + request = media_interaction_manager.create_or_replace( + user_id=userid, + channel=channel, + source=source, + username=username, + action=action, + keyword=content, + title=meta.name, + meta=meta, + items=medias, + ) + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + ) + + def _handle_media_selection( + self, + request: PendingMediaInteraction, + page_index: Optional[int], + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 处理媒体选择阶段的序号输入。 + """ + page_items, page, _ = self._page_items( + items=request.items, + page=request.page, + page_size=self._page_size(request.channel), + ) + request.page = page + if not page_index or page_index < 1 or page_index > len(page_items): + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + mediainfo: MediaInfo = page_items[page_index - 1] + request.current_media = mediainfo + + if request.action in {"Search", "ReSearch"}: + self._search_media_resources( + request=request, + mediainfo=mediainfo, + channel=channel, + source=source, + userid=userid, + username=username, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + return + + if request.action in {"Subscribe", "ReSubscribe"}: + self._subscribe_media( + request=request, + mediainfo=mediainfo, + channel=channel, + source=source, + userid=userid, + username=username, + ) + + def _search_media_resources( + self, + request: PendingMediaInteraction, + mediainfo: MediaInfo, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 根据已选媒体搜索资源,并切换到资源选择阶段。 + """ + exist_flag, no_exists = DownloadChain().get_no_exists_info( + meta=request.meta, + mediainfo=mediainfo, + ) + if exist_flag and request.action == "Search": + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】", + save_history=False, + ) + ) + return + if exist_flag: + no_exists = self._get_noexits_info(request.meta, mediainfo) + + messages = self._build_no_exists_messages( + mediainfo=mediainfo, + no_exists=no_exists, + show_missing_only=request.action == "Search", + ) + if messages: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"{mediainfo.title_year}:\n" + "\n".join(messages), + save_history=False, + ) + ) + + logger.info("开始搜索 %s ...", mediainfo.title_year) + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...", + save_history=False, + ) + ) + + contexts = SearchChain().process(mediainfo=mediainfo, no_exists=no_exists) + if not contexts: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"{mediainfo.title}{request.meta.sea} 未搜索到需要的资源!", + save_history=False, + ) + ) + return + + contexts = TorrentHelper().sort_torrents(contexts) + if self._should_auto_download(userid): + logger.info("用户 %s 在自动下载用户中,开始自动择优下载 ...", userid) + request.phase = "torrent" + request.page = 0 + request.title = mediainfo.title + request.items = list(contexts) + if self._prompt_download_dir_selection( + request=request, + download_mode="auto", + channel=channel, + source=source, + userid=userid, + username=username, + no_exists=no_exists, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ): + return + self._auto_download( + request=request, + cache_list=contexts, + channel=channel, + source=source, + userid=userid, + username=username, + no_exists=no_exists, + ) + return + + request.phase = "torrent" + request.page = 0 + request.title = mediainfo.title + request.items = list(contexts) + self._render_interaction( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + + def _subscribe_media( + self, + request: PendingMediaInteraction, + mediainfo: MediaInfo, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 根据已选媒体创建订阅或洗版订阅。 + """ + best_version = request.action == "ReSubscribe" + if not best_version: + exist_flag, _ = DownloadChain().get_no_exists_info( + meta=request.meta, + mediainfo=mediainfo, + ) + if exist_flag: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】", + save_history=False, + ) + ) + return + + mp_name = ( + UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) + if channel + else None + ) + SubscribeChain().add( + title=mediainfo.title, + year=mediainfo.year, + mtype=mediainfo.type, + media_source=mediainfo.media_source, + media_id=mediainfo.media_id, + season=request.meta.begin_season, + channel=channel, + source=source, + userid=userid, + username=mp_name or username, + best_version=best_version, + ) + + def _handle_torrent_selection( + self, + request: PendingMediaInteraction, + page_index: Optional[int], + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 处理资源选择阶段的下载操作。 + """ + if request.phase != "torrent": + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + if page_index == 0: + if self._prompt_download_dir_selection( + request=request, + download_mode="auto", + channel=channel, + source=source, + userid=userid, + username=username, + ): + return + self._auto_download( + request=request, + cache_list=request.items, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + page_items, page, _ = self._page_items( + items=request.items, + page=request.page, + page_size=self._page_size(request.channel), + ) + request.page = page + if not page_index or page_index < 1 or page_index > len(page_items): + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + context: Context = page_items[page_index - 1] + if self._prompt_download_dir_selection( + request=request, + download_mode="single", + channel=channel, + source=source, + userid=userid, + username=username, + context=context, + ): + return + DownloadChain().download_single( + context, + channel=channel, + source=source, + userid=userid, + username=username, + ) + + def _prompt_download_dir_selection( + self, + request: PendingMediaInteraction, + download_mode: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + context: Optional[Context] = None, + no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> bool: + """ + 在下载前进入目录选择阶段;没有配置下载目录时保持原下载流程。 + """ + media_info = context.media_info if context else request.current_media + download_dirs = self._get_download_dirs(media_info) + if not download_dirs: + return False + if len(download_dirs) == 1 and not self._is_auto_download_dir(download_dirs[0]): + return False + + request.pending_torrent_page = request.page + request.phase = "download-dir" + request.page = 0 + request.download_dirs = download_dirs + request.pending_download_mode = download_mode + request.pending_download_context = context + request.pending_no_exists = no_exists + self._post_download_dirs_message( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + return True + + def _handle_download_dir_selection( + self, + request: PendingMediaInteraction, + page_index: Optional[int], + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 处理下载目录阶段的序号输入,并继续执行挂起的下载动作。 + """ + if request.phase != "download-dir": + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + page_items, page, _ = self._page_items( + items=request.download_dirs, + page=request.page, + page_size=self._page_size(request.channel), + ) + request.page = page + if not page_index or page_index < 1 or page_index > len(page_items): + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + ) + return + + download_dir = page_items[page_index - 1] + if self._is_auto_download_dir(download_dir): + self._execute_pending_download( + request=request, + channel=channel, + source=source, + userid=userid, + username=username, + save_path=None, + ) + return + + save_path = download_dir.save_path or download_dir.download_path + if not save_path: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="下载目录配置无效!", + ) + return + self._execute_pending_download( + request=request, + channel=channel, + source=source, + userid=userid, + username=username, + save_path=save_path, + ) + + def _execute_pending_download( + self, + request: PendingMediaInteraction, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + save_path: Optional[str], + ) -> None: + """ + 使用用户确认的下载目录执行单资源下载或自动择优下载。 + """ + download_mode = request.pending_download_mode + if download_mode == "single" and request.pending_download_context: + context = request.pending_download_context + self._restore_torrent_phase(request) + DownloadChain().download_single( + context, + channel=channel, + source=source, + userid=userid, + username=username, + save_path=save_path, + ) + return + + if download_mode == "auto": + cache_list = list(request.items or []) + no_exists = request.pending_no_exists + self._restore_torrent_phase(request) + self._auto_download( + request=request, + cache_list=cache_list, + channel=channel, + source=source, + userid=userid, + username=username, + no_exists=no_exists, + save_path=save_path, + ) + return + + self._restore_torrent_phase(request) + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="下载操作已失效,请重新选择资源", + ) + + @staticmethod + def _restore_torrent_phase(request: PendingMediaInteraction) -> None: + """ + 下载动作完成或失效后恢复到资源列表阶段,便于用户继续选择其它资源。 + """ + request.phase = "torrent" + request.page = request.pending_torrent_page + request.download_dirs = [] + request.pending_download_mode = None + request.pending_download_context = None + request.pending_no_exists = None + request.pending_torrent_page = 0 + + def _auto_download( + self, + request: PendingMediaInteraction, + cache_list: List[Context], + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None, + save_path: Optional[str] = None, + ) -> None: + """ + 自动择优下载当前资源列表,并在未完成时补建订阅。 + """ + downloadchain = DownloadChain() + if no_exists is None: + exist_flag, no_exists = downloadchain.get_no_exists_info( + meta=request.meta, + mediainfo=request.current_media, + ) + if exist_flag: + no_exists = self._get_noexits_info(request.meta, request.current_media) + + downloads, lefts = downloadchain.batch_download( + contexts=cache_list, + no_exists=no_exists, + save_path=save_path, + channel=channel, + source=source, + userid=userid, + username=username, + ) + if downloads and not lefts: + logger.info("%s 下载完成", request.current_media.title_year) + return + + logger.info("%s 未下载未完整,添加订阅 ...", request.current_media.title_year) + if downloads and request.current_media.type == MediaType.TV: + note = [ + download.meta_info.begin_episode + for download in downloads + if download.meta_info.begin_episode + ] + else: + note = None + + mp_name = ( + UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) + if channel + else None + ) + SubscribeChain().add( + title=request.current_media.title, + year=request.current_media.year, + mtype=request.current_media.type, + media_source=request.current_media.media_source, + media_id=request.current_media.media_id, + season=request.meta.begin_season, + channel=channel, + source=source, + userid=userid, + username=mp_name or username, + state="R", + note=note, + ) + + def _render_interaction( + self, + request: PendingMediaInteraction, + channel: MessageChannel, + source: str, + userid: Union[str, int], + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 按当前阶段渲染媒体列表或资源列表。 + """ + if request.phase == "download-dir": + self._post_download_dirs_message( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + elif request.phase == "torrent": + self._post_torrents_message( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + else: + self._post_medias_message( + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + + def _post_medias_message( + self, + request: PendingMediaInteraction, + channel: MessageChannel, + source: str, + userid: Union[str, int], + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 发送或更新媒体选择列表。 + """ + page_items, page, total_pages = self._page_items( + items=request.items, + page=request.page, + page_size=self._page_size(channel), + ) + request.page = page + total = len(request.items) + if self._supports_interactive_buttons(channel): + title = f"【{request.title}】共找到{total}条相关信息,请选择操作" + buttons = self._create_media_buttons( + channel=channel, + request=request, + items=page_items, + total=total, + total_pages=total_pages, + ) + else: + if total > self._page_size(channel): + title = f"【{request.title}】共找到{total}条相关信息,请回复对应数字选择(p: 上一页 n: 下一页)" + else: + title = f"【{request.title}】共找到{total}条相关信息,请回复对应数字选择" + buttons = None + + self.post_medias_message( + Notification( + channel=channel, + source=source, + title=title, + userid=userid, + buttons=buttons, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + save_history=False, + ), + medias=page_items, + ) + + def _post_torrents_message( + self, + request: PendingMediaInteraction, + channel: MessageChannel, + source: str, + userid: Union[str, int], + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 发送或更新资源选择列表。 + """ + page_items, page, total_pages = self._page_items( + items=request.items, + page=request.page, + page_size=self._page_size(channel), + ) + request.page = page + total = len(request.items) + if self._supports_interactive_buttons(channel): + title = f"【{request.title}】共找到{total}条相关资源,请选择下载" + buttons = self._create_torrent_buttons( + channel=channel, + request=request, + items=page_items, + total=total, + total_pages=total_pages, + ) + else: + if total > self._page_size(channel): + title = f"【{request.title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择 p: 上一页 n: 下一页)" + else: + title = f"【{request.title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)" + buttons = None + + self.post_torrents_message( + Notification( + channel=channel, + source=source, + title=title, + userid=userid, + link=settings.MP_DOMAIN("#/resource"), + buttons=buttons, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + save_history=False, + ), + torrents=page_items, + ) + + def _post_download_dirs_message( + self, + request: PendingMediaInteraction, + channel: MessageChannel, + source: str, + userid: Union[str, int], + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> None: + """ + 发送或更新下载目录选择列表。 + """ + page_items, page, total_pages = self._page_items( + items=request.download_dirs, + page=request.page, + page_size=self._page_size(channel), + ) + request.page = page + total = len(request.download_dirs) + if self._supports_interactive_buttons(channel): + title = f"【{request.title}】请选择下载目录" + buttons = self._create_download_dir_buttons( + channel=channel, + request=request, + items=page_items, + total=total, + total_pages=total_pages, + ) + else: + if total > self._page_size(channel): + title = f"【{request.title}】请选择下载目录,请回复对应数字(p: 上一页 n: 下一页)" + else: + title = f"【{request.title}】请选择下载目录,请回复对应数字" + buttons = None + + text = "\n".join( + f"{index}. {self._format_download_dir_label(download_dir)}" + for index, download_dir in enumerate(page_items, start=1) + ) + self.post_message( + Notification( + channel=channel, + source=source, + title=title, + text=text, + userid=userid, + buttons=buttons, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + save_history=False, + ) + ) + + def _create_media_buttons( + self, + channel: MessageChannel, + request: PendingMediaInteraction, + items: List[MediaInfo], + total: int, + total_pages: int, + ) -> List[List[Dict[str, str]]]: + """ + 为媒体列表生成选择和翻页按钮。 + """ + buttons: List[List[Dict[str, str]]] = [] + max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) + max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) + + current_row: List[Dict[str, str]] = [] + for index, media in enumerate(items, start=1): + if max_per_row == 1: + button_text = f"{index}. {media.title_year}" + if len(button_text) > max_text_length: + button_text = button_text[: max_text_length - 3] + "..." + buttons.append( + [ + { + "text": button_text, + "callback_data": f"media:{request.request_id}:select:{index}", + } + ] + ) + continue + + current_row.append( + { + "text": f"{index}", + "callback_data": f"media:{request.request_id}:select:{index}", + } + ) + if len(current_row) == max_per_row or index == len(items): + buttons.append(current_row) + current_row = [] + + if total > self._page_size(channel): + buttons.extend(self._navigation_buttons(request, total_pages)) + return buttons + + def _create_torrent_buttons( + self, + channel: MessageChannel, + request: PendingMediaInteraction, + items: List[Context], + total: int, + total_pages: int, + ) -> List[List[Dict[str, str]]]: + """ + 为资源列表生成下载和翻页按钮。 + """ + buttons: List[List[Dict[str, str]]] = [ + [ + { + "text": "🤖 自动选择下载", + "callback_data": f"media:{request.request_id}:download:0", + } + ] + ] + max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) + max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) + + current_row: List[Dict[str, str]] = [] + for index, context in enumerate(items, start=1): + torrent = context.torrent_info + if max_per_row == 1: + button_text = f"{index}. {torrent.site_name} - {torrent.seeders}↑" + if len(button_text) > max_text_length: + button_text = button_text[: max_text_length - 3] + "..." + buttons.append( + [ + { + "text": button_text, + "callback_data": f"media:{request.request_id}:download:{index}", + } + ] + ) + continue + + current_row.append( + { + "text": f"{index}", + "callback_data": f"media:{request.request_id}:download:{index}", + } + ) + if len(current_row) == max_per_row or index == len(items): + buttons.append(current_row) + current_row = [] + + if total > self._page_size(channel): + buttons.extend(self._navigation_buttons(request, total_pages)) + return buttons + + def _create_download_dir_buttons( + self, + channel: MessageChannel, + request: PendingMediaInteraction, + items: List[DownloadDirectory], + total: int, + total_pages: int, + ) -> List[List[Dict[str, str]]]: + """ + 为下载目录列表生成选择和翻页按钮。 + """ + buttons: List[List[Dict[str, str]]] = [] + max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) + max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) + + current_row: List[Dict[str, str]] = [] + for index, download_dir in enumerate(items, start=1): + if max_per_row == 1: + button_text = f"{index}. {self._format_download_dir_label(download_dir)}" + if len(button_text) > max_text_length: + button_text = button_text[: max_text_length - 3] + "..." + buttons.append( + [ + { + "text": button_text, + "callback_data": f"media:{request.request_id}:download-dir:{index}", + } + ] + ) + continue + + current_row.append( + { + "text": f"{index}", + "callback_data": f"media:{request.request_id}:download-dir:{index}", + } + ) + if len(current_row) == max_per_row or index == len(items): + buttons.append(current_row) + current_row = [] + + if total > self._page_size(channel): + buttons.extend(self._navigation_buttons(request, total_pages)) + return buttons + + def _has_next_page(self, request: PendingMediaInteraction) -> bool: + """ + 判断当前视图是否还有下一页。 + """ + _, page, total_pages = self._page_items( + items=self._get_current_phase_items(request), + page=request.page, + page_size=self._page_size(request.channel), + ) + return page < total_pages - 1 + + @staticmethod + def _get_current_phase_items(request: PendingMediaInteraction) -> List[Any]: + """ + 获取当前阶段用于分页的数据列表。 + """ + if request.phase == "download-dir": + return request.download_dirs + return request.items + + @staticmethod + def _navigation_buttons( + request: PendingMediaInteraction, + total_pages: int, + ) -> List[List[Dict[str, str]]]: + """ + 按当前页状态生成上一页和下一页按钮。 + """ + buttons: List[List[Dict[str, str]]] = [] + nav_row: List[Dict[str, str]] = [] + if request.page > 0: + nav_row.append( + { + "text": "⬅️ 上一页", + "callback_data": f"media:{request.request_id}:page-prev", + } + ) + if request.page < total_pages - 1: + nav_row.append( + { + "text": "下一页 ➡️", + "callback_data": f"media:{request.request_id}:page-next", + } + ) + if nav_row: + buttons.append(nav_row) + return buttons + + @staticmethod + def _page_items( + items: List[Any], + page: int, + page_size: int, + ) -> Tuple[List[Any], int, int]: + """ + 返回当前页数据,并把页码限制在有效范围内。 + """ + total_pages = max(1, math.ceil(len(items) / page_size)) if page_size else 1 + page = min(max(0, page), total_pages - 1) + start = page * page_size + end = start + page_size + return items[start:end], page, total_pages + + @classmethod + def _get_download_dirs(cls, media_info: Optional[MediaInfo] = None) -> List[DownloadDirectory]: + """ + 获取可供消息交互选择的下载目录。 + """ + dir_infos = [ + dir_info + for dir_info in DirectoryHelper().get_download_dirs() + if dir_info.download_path + ] + download_dirs = [ + DownloadDirectory( + name=dir_info.name, + storage=dir_info.storage or "local", + download_path=dir_info.download_path, + save_path=FileURI( + storage=dir_info.storage or "local", + path=dir_info.download_path, + ).uri, + priority=dir_info.priority, + media_type=dir_info.media_type, + media_category=dir_info.media_category, + ) + for dir_info in dir_infos + if cls._match_download_dir_media(dir_info, media_info) + ] + if not download_dirs: + return [] + if len(download_dirs) == 1: + return download_dirs + return [cls._build_auto_download_dir(), *download_dirs] + + @classmethod + def _build_auto_download_dir(cls) -> DownloadDirectory: + """ + 构造自动匹配下载目录选项。 + """ + return DownloadDirectory( + name=cls._auto_download_dir_name, + storage="local", + priority=-1, + ) + + @classmethod + def _is_auto_download_dir(cls, download_dir: DownloadDirectory) -> bool: + """ + 判断是否为自动匹配下载目录选项。 + """ + return ( + download_dir.name == cls._auto_download_dir_name + and not download_dir.download_path + and not download_dir.save_path + ) + + @staticmethod + def _match_download_dir_media( + dir_info: TransferDirectoryConf, + media_info: Optional[MediaInfo], + ) -> bool: + """ + 判断下载目录是否适用于当前媒体。 + """ + if not media_info or not media_info.type: + return True + + if dir_info.media_type: + media_type_values = ( + {media_info.type.value, media_info.type.to_agent()} + if isinstance(media_info.type, MediaType) + else {str(media_info.type)} + ) + if dir_info.media_type not in media_type_values: + return False + + if dir_info.media_category and dir_info.media_category != media_info.category: + return False + + return True + + @staticmethod + def _format_download_dir_label(download_dir: DownloadDirectory) -> str: + """ + 格式化下载目录展示名称,优先显示用户配置的目录名称。 + """ + save_path = download_dir.save_path or download_dir.download_path or "" + name = download_dir.name or save_path or "下载目录" + if save_path and name != save_path: + return f"{name} ({save_path})" + return name + + def _page_size(self, channel: Optional[MessageChannel]) -> int: + """ + 按渠道交互能力选择分页大小。 + """ + return ( + self._button_page_size + if self._supports_interactive_buttons(channel) + else self._text_page_size + ) + + @staticmethod + def _supports_interactive_buttons(channel: Optional[MessageChannel]) -> bool: + """ + 判断渠道是否同时支持按钮展示与按钮回调。 + """ + return bool( + channel + and ChannelCapabilityManager.supports_buttons(channel) + and ChannelCapabilityManager.supports_callbacks(channel) + ) + + @staticmethod + def _build_no_exists_messages( + mediainfo: MediaInfo, + no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]], + show_missing_only: bool, + ) -> List[str]: + """ + 将缺失集信息转换为可发送的文案。 + """ + if not no_exists: + return [] + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) + season_map = no_exists.get(mediakey) or {} + if show_missing_only: + return [ + f"第 {sea} 季缺失 {episode_rules.compact_numbers(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集" + for sea, no_exist in season_map.items() + ] + return [ + f"第 {sea} 季总 {no_exist.total_episode} 集" + for sea, no_exist in season_map.items() + ] + + @staticmethod + def _should_auto_download(userid: Union[str, int]) -> bool: + """ + 判断当前用户是否命中自动下载名单。 + """ + auto_download_user = settings.AUTO_DOWNLOAD_USER + return bool( + auto_download_user + and ( + auto_download_user == "all" + or any(userid == user for user in auto_download_user.split(",")) + ) + ) + + def _post_invalid_input( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: Optional[str], + title: str = "输入有误!", + ) -> None: + """ + 发送统一的非法输入提示。 + """ + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=title, + save_history=False, + ) + ) diff --git a/app/chain/message.py b/app/chain/message.py index 7d21432bf..10f5616c5 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -11,30 +11,29 @@ from pathlib import Path from typing import Any, Optional, Dict, Union, List, Tuple from urllib.parse import unquote, urlparse -from app.agent.orchestrator import ReplyMode, agent_manager +from app.agent.orchestrator import agent_manager from app.agent.llm import AgentCapabilityManager, LLMHelper -from app.agent.prompt.transfer_redo import build_manual_redo_prompt from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain -from app.chain.site import SiteChain, site_interaction_manager -from app.chain.skills import SkillsChain, skills_interaction_manager -from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager +from app.chain.site import SiteChain +from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain +from app.chain.interaction import MediaInteractionChain as _MediaInteractionChain from app.runtime.config import settings, global_vars from app.domain.context import MediaInfo, Context from app.domain.meta.metabase import MetaBase -from app.db.models import TransferHistory -from app.db.oper.transferhistory import TransferHistoryOper from app.db.oper.user import UserOper from app.application.directory import DirectoryHelper -from app.application.messaging.interaction import ( - agent_interaction_manager, - media_interaction_manager, - plugin_input_interaction_manager, - PendingMediaInteraction, -) +from app.application.messaging.agent import agent_interaction_manager, parse_agent_choice_callback +from app.application.messaging.interaction import InteractionContext, InteractionDispatch +from app.application.messaging.media import media_interaction_manager +from app.application.messaging.plugin import PluginInputInteractionHandler +from app.application.messaging.router import CallbackRoute, InteractionRouter, SessionRoute +from app.application.messaging.site import site_interaction_manager +from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager +from app.application.messaging.subscribe import subscribe_interaction_manager from app.application.torrent import TorrentHelper from app.runtime.log import logger from app.schemas import CommingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Notification @@ -140,7 +139,13 @@ class MessageChain(ChainBase): images = info.images audio_refs = info.audio_refs files = info.files - if not text and not images and not audio_refs and not files: + # 结构化按钮回调数据,优先于 CALLBACK: 文本前缀 + callback_data = ( + str(info.callback_data).strip() + if info.callback_data + else None + ) + if not text and not callback_data and not images and not audio_refs and not files: logger.debug(f"未识别到消息内容::{body}{form}{args}") return @@ -162,6 +167,7 @@ class MessageChain(ChainBase): images=images, audio_refs=audio_refs, files=files, + callback_data=callback_data, ) def handle_message( @@ -178,12 +184,18 @@ class MessageChain(ChainBase): files: Optional[List[CommingMessage.MessageAttachment]] = None, reply_to_message_id: Optional[Union[str, int]] = None, is_channel_admin: Optional[bool] = None, + callback_data: Optional[str] = None, ) -> None: """ 识别消息内容,执行操作 """ images = CommingMessage.MessageImage.normalize_list(images) + # 兼容归一化:结构化回调优先,CALLBACK: 文本前缀作为旧渠道和插件直接调用的兼容入口 + normalized_callback = str(callback_data or "").strip() or None + if normalized_callback is None and str(text or "").startswith("CALLBACK:"): + normalized_callback = str(text)[9:].strip() or None + processing_status = None processing_finish_deferred = False try: @@ -229,13 +241,19 @@ class MessageChain(ChainBase): ): return - if self._handle_plugin_input_interaction( - channel=channel, - source=source, - userid=userid, - username=username, + interaction_context = InteractionContext( + channel=channel, + source=source, + user_id=userid, + username=username, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + is_channel_admin=is_channel_admin, + ) + + if PluginInputInteractionHandler(messenger=self).handle_text( + context=interaction_context, text=text, - original_chat_id=original_chat_id, reply_to_message_id=reply_to_message_id, images=images, audio_refs=audio_refs, @@ -247,12 +265,14 @@ class MessageChain(ChainBase): is_agent_message = self._is_agent_message( userid=userid, text=text, + callback_data=normalized_callback, images=images, files=files, has_audio_input=has_audio_input, ) - if not text.startswith("CALLBACK:") and not is_agent_message: + # 回调消息不写入普通用户消息历史 + if normalized_callback is None and not is_agent_message: self._record_user_message( channel=channel, source=source, @@ -286,6 +306,7 @@ class MessageChain(ChainBase): files=files, has_audio_input=has_audio_input, processing_status=processing_status, + callback_data=normalized_callback, ) is True finally: if not processing_finish_deferred: @@ -363,37 +384,37 @@ class MessageChain(ChainBase): processing_status: Optional[_ProcessingStatus] = None, reply_to_message_id: Optional[Union[str, int]] = None, is_channel_admin: Optional[bool] = None, + callback_data: Optional[str] = None, ) -> bool: """执行实际消息路由,便于统一包裹处理中状态。""" - if text.startswith("CALLBACK:"): + context = InteractionContext( + channel=channel, + source=source, + user_id=userid, + username=username, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + is_channel_admin=is_channel_admin, + ) + + if callback_data: if ChannelCapabilityManager.supports_callbacks(channel): return self._handle_callback( - text=text, - channel=channel, - source=source, - userid=userid, - username=username, - is_channel_admin=is_channel_admin, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - processing_status=processing_status, + callback_data=callback_data, + context=context, ) else: logger.warning( "渠道 %s 不支持回调,但收到了回调消息:%s", channel.value, - text, + callback_data, ) return False - if self._handle_plugin_input_interaction( - channel=channel, - source=source, - userid=userid, - username=username, + if PluginInputInteractionHandler(messenger=self).handle_text( + context=context, text=text, - original_chat_id=original_chat_id, reply_to_message_id=reply_to_message_id, images=images, audio_refs=audio_refs, @@ -448,46 +469,9 @@ class MessageChain(ChainBase): has_audio_input=has_audio_input, ) - latest_slash_interaction = self._get_latest_slash_interaction(userid) - if latest_slash_interaction == "sites": - if SiteChain().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ): - return False - - if latest_slash_interaction == "subscribes": - if SubscribeChain().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ): - return False - - if latest_slash_interaction == "skills": - if SkillsChain().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ): - return False - - if media_interaction_manager.get_by_user(userid): - if MediaInteractionChain().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ): - return False + # 最近活动的传统交互会话(按创建时间选择,避免旧会话抢占新输入) + if self._interaction_router().dispatch_active_text(context, text): + return False if ( not no_ai_requested @@ -509,7 +493,7 @@ class MessageChain(ChainBase): has_audio_input=has_audio_input, ) - if MediaInteractionChain().handle_text_interaction( + if _MediaInteractionChain().handle_text_interaction( channel=channel, source=source, userid=userid, @@ -531,122 +515,6 @@ class MessageChain(ChainBase): ) return False - def _handle_plugin_input_interaction( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - text: str, - original_chat_id: Optional[Union[str, int]] = None, - images: Optional[List[CommingMessage.MessageImage]] = None, - audio_refs: Optional[List[str]] = None, - files: Optional[List[CommingMessage.MessageAttachment]] = None, - has_audio_input: bool = False, - reply_to_message_id: Optional[Union[str, int]] = None, - ) -> bool: - """ - 将插件输入会话中的下一条普通文本派发给指定插件。 - """ - if not text or not text.strip() or images or audio_refs or files or has_audio_input: - return False - if text.startswith("CALLBACK:"): - return False - - is_cancel_text = text.strip().lower() in {"取消", "退出", "q", "quit", "exit"} - request, status = plugin_input_interaction_manager.consume_by_user( - userid, - channel, - source, - original_chat_id, - reply_to_message_id=reply_to_message_id, - bypass_reply_check=is_cancel_text, - ) - if not request: - return False - - if status == "expired": - self.eventmanager.send_event( - EventType.MessageAction, - { - "plugin_id": request.plugin_id, - "__mp_target_plugin_id": request.plugin_id, - "text": f"plugin_input_expired|{request.request_id}", - "userid": userid, - "channel": channel, - "source": source, - "username": username, - "chat_id": original_chat_id, - "reply_to_message_id": reply_to_message_id, - "prompt_id": request.prompt_id, - "input_session_id": request.request_id, - "expired": True, - "payload": request.payload, - }, - ) - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="插件输入已超时,请重新发起操作。", - save_history=False, - ) - ) - return not text.strip().startswith("/") - - if is_cancel_text: - self.eventmanager.send_event( - EventType.MessageAction, - { - "plugin_id": request.plugin_id, - "__mp_target_plugin_id": request.plugin_id, - "text": f"plugin_input_cancel|{request.request_id}", - "userid": userid, - "channel": channel, - "source": source, - "username": username, - "chat_id": original_chat_id, - "reply_to_message_id": reply_to_message_id, - "prompt_id": request.prompt_id, - "input_session_id": request.request_id, - "cancelled": True, - "payload": request.payload, - }, - ) - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="已取消插件输入", - save_history=False, - ) - ) - return True - - self.eventmanager.send_event( - EventType.MessageAction, - { - "plugin_id": request.plugin_id, - "__mp_target_plugin_id": request.plugin_id, - "text": f"plugin_input|{request.request_id}", - "input_text": text, - "userid": userid, - "channel": channel, - "source": source, - "username": username, - "chat_id": original_chat_id, - "reply_to_message_id": reply_to_message_id, - "prompt_id": request.prompt_id, - "input_session_id": request.request_id, - "payload": request.payload, - }, - ) - return True - @classmethod def _strip_no_ai_prefix(cls, text: str) -> Tuple[bool, str]: """ @@ -670,6 +538,7 @@ class MessageChain(ChainBase): self, userid: Union[str, int], text: str, + callback_data: Optional[str] = None, images: Optional[List[CommingMessage.MessageImage]] = None, files: Optional[List[CommingMessage.MessageAttachment]] = None, has_audio_input: bool = False, @@ -677,8 +546,8 @@ class MessageChain(ChainBase): """ 判断本条消息是否会进入 Agent worker,由 Agent worker 管理 typing 生命周期。 """ - if text.startswith("CALLBACK:"): - return self._parse_agent_choice_callback(text[9:]) is not None + if callback_data: + return parse_agent_choice_callback(callback_data) is not None if self._has_ai_prefix(text): return True if text.startswith("/"): @@ -688,9 +557,7 @@ class MessageChain(ChainBase): and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input) ): return False - if self._get_latest_slash_interaction(userid): - return False - if media_interaction_manager.get_by_user(userid): + if self._interaction_router().has_pending(userid): return False return True @@ -749,231 +616,176 @@ class MessageChain(ChainBase): chat_id=status.chat_id or original_chat_id, ) - def _handle_callback( - self, - text: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - processing_status: Optional[_ProcessingStatus] = None, - is_channel_admin: Optional[bool] = None, - ) -> bool: - """ - 处理按钮回调 - """ + def _interaction_router(self) -> InteractionRouter: + """构造交互路由器,文本会话按创建时间选择,回调路由注册顺序即优先级。""" - # 提取回调数据 - callback_data = text[9:] # 去掉 "CALLBACK:" 前缀 - logger.info(f"处理按钮回调:{callback_data}") + def session_text(handle): + """包装传统交互入口为会话路由的文本处理函数,保持懒构造。""" + def _handle(context: InteractionContext, text: str) -> bool: + return bool(handle( + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, + text=text, + )) + return _handle - if self._handle_transfer_callback( + def callback_dispatch(handle): + """包装传统回调入口为回调路由的派发函数,保持懒构造。""" + def _dispatch(callback_data: str, context: InteractionContext) -> InteractionDispatch: + return InteractionDispatch(handled=bool(handle( + callback_data=callback_data, + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, + original_message_id=context.original_message_id, + original_chat_id=context.original_chat_id, + ))) + return _dispatch + + session_routes = [ + SessionRoute( + name="sites", + get_pending=site_interaction_manager.get_by_user, + handle_text=session_text(lambda **kw: SiteChain().handle_text_interaction(**kw)), + ), + SessionRoute( + name="subscribes", + get_pending=subscribe_interaction_manager.get_by_user, + handle_text=session_text(lambda **kw: SubscribeChain().handle_text_interaction(**kw)), + ), + SessionRoute( + name="skills", + get_pending=skill_interaction_manager.get_by_user, + handle_text=session_text( + lambda **kw: SkillInteractionHandler(messenger=self).handle_text_interaction(**kw) + ), + ), + SessionRoute( + name="media", + get_pending=media_interaction_manager.get_by_user, + handle_text=session_text(lambda **kw: _MediaInteractionChain().handle_text_interaction(**kw)), + ), + ] + + def _dispatch_agent_choice(callback_data: str, context: InteractionContext) -> InteractionDispatch: + handled = self._handle_agent_choice_callback( callback_data=callback_data, - channel=channel, - source=source, - userid=userid, - username=username, - ): - return False + context=context, + ) + # Agent 选择回调会接续会话,需要延迟结束处理中状态 + return InteractionDispatch(handled=handled, defer_processing_finish=handled) - if SkillsChain().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, - ): - return False - - if SiteChain().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, - ): - return False - - if SubscribeChain().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, - ): - return False - - if MediaInteractionChain().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, - ): - return False - - if self._handle_agent_choice_callback( - callback_data=callback_data, - channel=channel, - source=source, - userid=userid, - username=username, - is_channel_admin=is_channel_admin, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ): - return True - - # 插件消息的事件回调 [PLUGIN]插件ID|内容 - if callback_data.startswith("[PLUGIN]"): - # 提取插件ID和内容 - plugin_id, content = callback_data.split("|", 1) + def _dispatch_plugin_callback(callback_data: str, context: InteractionContext) -> InteractionDispatch: + parsed = PluginInputInteractionHandler.parse_callback(callback_data) + if not parsed: + return InteractionDispatch(handled=False) + plugin_id, content = parsed # 广播给插件处理 self.eventmanager.send_event( EventType.MessageAction, { - "plugin_id": plugin_id.replace("[PLUGIN]", ""), + "plugin_id": plugin_id, "text": content, - "userid": userid, - "channel": channel, - "source": source, - "original_message_id": original_message_id, - "original_chat_id": original_chat_id, + "userid": context.user_id, + "channel": context.channel, + "source": context.source, + "original_message_id": context.original_message_id, + "original_chat_id": context.original_chat_id, }, ) - return False + return InteractionDispatch(handled=True) + + callback_routes = [ + CallbackRoute( + name="transfer", + matches=lambda data: TransferChain.parse_failed_transfer_callback(data) is not None, + dispatch=lambda data, context: InteractionDispatch( + handled=TransferChain().handle_failed_transfer_callback( + callback_data=data, + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, + ) + ), + ), + CallbackRoute( + name="skill", + matches=lambda data: data.startswith("skills:"), + dispatch=callback_dispatch( + lambda **kw: SkillInteractionHandler(messenger=self).handle_callback_interaction(**kw) + ), + ), + CallbackRoute( + name="site", + matches=lambda data: data.startswith("sites:"), + dispatch=callback_dispatch(lambda **kw: SiteChain().handle_callback_interaction(**kw)), + ), + CallbackRoute( + name="subscribe", + matches=lambda data: data.startswith("subscribes:"), + dispatch=callback_dispatch(lambda **kw: SubscribeChain().handle_callback_interaction(**kw)), + ), + CallbackRoute( + name="media", + matches=lambda data: _MediaInteractionChain.parse_callback(data) is not None, + dispatch=callback_dispatch(lambda **kw: _MediaInteractionChain().handle_callback_interaction(**kw)), + ), + CallbackRoute( + name="agent_choice", + matches=lambda data: parse_agent_choice_callback(data) is not None, + dispatch=_dispatch_agent_choice, + ), + CallbackRoute( + name="plugin", + matches=lambda data: data.startswith("[PLUGIN]"), + dispatch=_dispatch_plugin_callback, + ), + ] + return InteractionRouter(session_routes=session_routes, callback_routes=callback_routes) + + def _handle_callback( + self, + callback_data: str, + context: InteractionContext, + ) -> bool: + """ + 处理按钮回调。 + + :return: 是否延迟结束处理中状态(Agent 选择回调会等待会话接续) + """ + logger.info(f"处理按钮回调:{callback_data}") + result = self._interaction_router().dispatch_callback(context, callback_data) + if result.handled: + return result.defer_processing_finish logger.error(f"回调数据格式错误:{callback_data}") self.post_message( Notification( - channel=channel, - source=source, - userid=userid, - username=username, + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, title="回调数据格式错误,请检查!", save_history=False, ) ) return False - @staticmethod - def _get_latest_slash_interaction(userid: Union[str, int]) -> Optional[str]: - """ - 返回当前用户最近一次激活的 slash 交互类型。 - """ - candidates = [] - for name, manager in ( - ("sites", site_interaction_manager), - ("subscribes", subscribe_interaction_manager), - ("skills", skills_interaction_manager), - ): - request = manager.get_by_user(userid) - if request: - candidates.append((request.created_at, name)) - if not candidates: - return None - return max(candidates, key=lambda item: item[0])[1] - - @staticmethod - def _parse_transfer_callback( - callback_data: str, - ) -> Optional[tuple[str, int]]: - """ - 解析整理失败通知按钮回调。 - """ - for prefix, action in ( - ("transfer_retry_", "retry"), - ("transfer_ai_retry_", "ai_retry"), - ): - if callback_data.startswith(prefix): - history_id = callback_data.replace(prefix, "", 1) - if history_id.isdigit(): - return action, int(history_id) - return None - - def _handle_transfer_callback( - self, - callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> bool: - """ - 处理整理失败通知中的重试类按钮。 - """ - callback = self._parse_transfer_callback(callback_data) - if not callback: - return False - - action, history_id = callback - if action == "retry": - self._retry_transfer_history( - history_id=history_id, - channel=channel, - source=source, - userid=userid, - username=username, - ) - else: - self._take_over_transfer_history_by_ai( - history_id=history_id, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - @staticmethod - def _parse_agent_choice_callback( - callback_data: str, - ) -> Optional[tuple[str, int]]: - """ - 解析 Agent 按钮选择回调。 - """ - if callback_data.startswith("agent_interaction:choice:"): - try: - _, _, request_id, option_index = callback_data.split(":", 3) - except ValueError: - return None - elif callback_data.startswith("agent_choice:"): - # 兼容旧格式,避免已发送的按钮失效 - try: - _, request_id, option_index = callback_data.split(":", 2) - except ValueError: - return None - else: - return None - if not request_id or not option_index.isdigit(): - return None - return request_id, int(option_index) def _handle_agent_choice_callback( self, + *, callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - is_channel_admin: Optional[bool] = None, + context: InteractionContext, ) -> bool: """ 将 Agent 按钮选择回传为同一会话中的下一条用户消息。 """ - callback = self._parse_agent_choice_callback(callback_data) + callback = parse_agent_choice_callback(callback_data) if not callback: return False @@ -981,15 +793,15 @@ class MessageChain(ChainBase): resolved = agent_interaction_manager.resolve( request_id=request_id, option_index=option_index, - user_id=str(userid), + user_id=str(context.user_id), ) if not resolved: self.post_message( Notification( - channel=channel, - source=source, - userid=userid, - username=username, + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, title="该选择已失效,请重新发起选择", save_history=False, ) @@ -999,22 +811,22 @@ class MessageChain(ChainBase): request, option = resolved selected_text = option.value self._update_interaction_message_feedback( - channel=channel, - source=source, - original_message_id=original_message_id, - original_chat_id=original_chat_id, + channel=context.channel, + source=context.source, + original_message_id=context.original_message_id, + original_chat_id=context.original_chat_id, title=request.title, prompt=request.prompt, selected_label=option.label, ) - self._bind_session_id(userid, request.session_id) + self._bind_session_id(context.user_id, request.session_id) return self._handle_ai_message( text=selected_text, - channel=channel, - source=source, - userid=userid, - username=username, - is_channel_admin=is_channel_admin, + channel=context.channel, + source=context.source, + userid=context.user_id, + username=context.username, + is_channel_admin=context.is_channel_admin, session_id=request.session_id, ) @@ -1047,156 +859,6 @@ class MessageChain(ChainBase): text=feedback_text, ) - def _retry_transfer_history( - self, - history_id: int, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 立即重新整理一条失败的整理记录。 - """ - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"开始重新整理记录 #{history_id} ...", - save_history=False, - ) - ) - - state, errmsg = TransferChain().redo_transfer_history(history_id) - if state: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"整理记录 #{history_id} 已重新整理", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - return - - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="重新整理失败", - text=errmsg, - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - def _take_over_transfer_history_by_ai( - self, - history_id: int, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 由智能助手接管一条失败的整理记录。 - """ - - if not settings.AI_AGENT_ENABLE: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="MoviePilot智能助手未启用,请在系统设置中启用", - save_history=False, - ) - ) - return - - history = TransferHistoryOper().get(history_id) - if not history: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="重新整理失败", - text=f"整理记录 #{history_id} 不存在", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - return - - redo_prompt = build_manual_redo_prompt(history) - - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"已将整理记录 #{history_id} 交给智能助手处理", - text="处理完成后会在这里回复结果。", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - async def _run_ai_takeover(): - final_output = "" - - def _capture_output(text_output: str): - nonlocal final_output - final_output = text_output or "" - - try: - await agent_manager.run_background_prompt( - message=redo_prompt, - session_prefix=f"__agent_manual_redo_{history_id}", - output_callback=_capture_output, - reply_mode=ReplyMode.CAPTURE_ONLY, - allow_message_tools=False, - ) - await self.async_post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="智能助手整理完成", - text=final_output.strip() - or f"整理记录 #{history_id} 已由智能助手处理完成。", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - except Exception as e: - await self.async_post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="智能助手整理失败", - text=str(e), - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - asyncio.run_coroutine_threadsafe(_run_ai_takeover(), global_vars.loop) - def _get_or_create_session_id(self, userid: Union[str, int]) -> str: """ 获取或创建会话ID @@ -2190,1541 +1852,3 @@ class MessageChain(ChainBase): logger.error(e) return None - -class MediaInteractionChain(ChainBase): - """ - 处理媒体搜索、订阅、资源选择和翻页等交互流程。 - """ - - _button_page_size = 8 - _text_page_size = 8 - _auto_download_dir_name = "自动匹配目录" - - @staticmethod - def has_pending_interaction(user_id: Union[str, int]) -> bool: - """ - 判断用户当前是否存在未结束的媒体交互。 - """ - return media_interaction_manager.get_by_user(user_id) is not None - - @staticmethod - def _get_noexits_info( - meta: MetaBase, mediainfo: MediaInfo - ) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]: - """ - 构造媒体缺失集信息,用于全量重搜或自动下载补全集数。 - """ - if mediainfo.type == MediaType.TV: - if not mediainfo.seasons: - mediainfo = MediaChain().recognize_media( - mtype=mediainfo.type, - media_source=resolve_media_identity(media=mediainfo)[0], - media_id=resolve_media_identity(media=mediainfo)[1], - cache=False, - ) - if not mediainfo: - logger.warn("媒体信息识别失败,无法补充季集信息") - return {} - if not mediainfo.seasons: - logger.warn( - "媒体信息中没有季集信息,标题:%s,tmdbid:%s,doubanid:%s", - mediainfo.title, - mediainfo.tmdb_id, - mediainfo.douban_id, - ) - return {} - - media_source, media_id = resolve_media_identity(media=mediainfo) - mediakey = build_media_key(media_source, media_id) - no_exists = {mediakey: {}} - if meta.begin_season is not None: - episodes = mediainfo.seasons.get(meta.begin_season) - if not episodes: - return {} - no_exists[mediakey][meta.begin_season] = NotExistMediaInfo( - season=meta.begin_season, - episodes=[], - total_episode=len(episodes), - start_episode=episodes[0], - ) - else: - for sea, eps in mediainfo.seasons.items(): - if not eps: - continue - no_exists[mediakey][sea] = NotExistMediaInfo( - season=sea, - episodes=[], - total_episode=len(eps), - start_episode=eps[0], - ) - return no_exists - return {} - - @staticmethod - def parse_callback( - callback_data: str, - ) -> Optional[Tuple[Optional[str], str, Optional[int]]]: - """ - 解析新旧两种媒体交互按钮格式。 - """ - if callback_data.startswith("media:"): - parts = callback_data.split(":") - if len(parts) < 3: - return None - request_id = parts[1] - action = parts[2] - index = None - if len(parts) >= 4 and parts[3].isdigit(): - index = int(parts[3]) - return request_id, action, index - - match = re.match(r"^(select|download)_(\d+)$", callback_data) - if match: - return None, match.group(1), int(match.group(2)) - if callback_data == "page_p": - return None, "page-prev", None - if callback_data == "page_n": - return None, "page-next", None - return None - - def handle_callback_interaction( - self, - callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> bool: - """ - 处理按钮回调,并将当前视图刷新到原消息上。 - """ - parsed = self.parse_callback(callback_data) - if not parsed: - return False - - request_id, action, index = parsed - if request_id: - request = media_interaction_manager.get_by_id(request_id, userid) - else: - request = media_interaction_manager.get_by_user(userid) - - if not request: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="交互已失效,请重新搜索或订阅", - save_history=False, - ) - ) - return True - - request.channel = channel - request.source = source - request.username = username - - if action == "page-prev": - if request.page <= 0: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="已经是第一页了!", - ) - return True - request.page -= 1 - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - return True - - if action == "page-next": - if not self._has_next_page(request): - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="已经是最后一页了!", - ) - return True - request.page += 1 - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - return True - - if action == "select": - self._handle_media_selection( - request=request, - page_index=index, - 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._handle_torrent_selection( - request=request, - page_index=index, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - if action == "download-dir": - self._handle_download_dir_selection( - request=request, - page_index=index, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - return False - - def handle_text_interaction( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - text: str, - ) -> bool: - """ - 处理文本式交互。 - - 有会话时优先处理数字选择和翻页;无会话时负责识别搜索/订阅类入口。 - """ - request = media_interaction_manager.get_by_user(userid) - normalized = (text or "").strip() - lowered = normalized.lower() - - if request and lowered in {"退出", "关闭", "q", "quit", "exit"}: - media_interaction_manager.remove(request.request_id) - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="媒体交互已结束", - save_history=False, - ) - ) - return True - - if normalized.isdigit(): - if not request: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - request.channel = channel - request.source = source - request.username = username - index = int(normalized) - if request.phase == "download-dir": - self._handle_download_dir_selection( - request=request, - page_index=index, - channel=channel, - source=source, - userid=userid, - username=username, - ) - elif request.phase == "torrent": - self._handle_torrent_selection( - request=request, - page_index=index, - channel=channel, - source=source, - userid=userid, - username=username, - ) - else: - self._handle_media_selection( - request=request, - page_index=index, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - if lowered in {"p", "prev", "上一页"}: - if not request: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - if request.page <= 0: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="已经是第一页了!", - ) - return True - request.page -= 1 - request.channel = channel - request.source = source - request.username = username - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - ) - return True - - if lowered in {"n", "next", "下一页"}: - if not request: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - if not self._has_next_page(request): - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="已经是最后一页了!", - ) - return True - request.page += 1 - request.channel = channel - request.source = source - request.username = username - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - ) - return True - - action, content = self._resolve_action(normalized) - if not action: - return False - - self._start_media_interaction( - action=action, - content=content, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - @staticmethod - def _resolve_action(text: str) -> Tuple[Optional[str], str]: - """ - 将用户输入归类为搜索、订阅或普通聊天。 - """ - if text.startswith("订阅"): - return "Subscribe", re.sub(r"订阅[::\s]*", "", text) - if text.startswith("洗版"): - return "ReSubscribe", re.sub(r"洗版[::\s]*", "", text) - if text.startswith("搜索") or text.startswith("下载"): - return "ReSearch", re.sub(r"(搜索|下载)[::\s]*", "", text) - if url_tools.is_link(text): - return None, text - if not title_rules.is_media_title_like(text): - return None, text - return "Search", text - - def _start_media_interaction( - self, - action: str, - content: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 根据用户输入搜索媒体,并进入媒体选择阶段。 - """ - meta, medias = MediaChain().search(content) - if not meta.name: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="无法识别输入内容!", - ) - return - if not medias: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"{meta.name} 没有找到对应的媒体信息!", - save_history=False, - ) - ) - return - - logger.info("搜索到 %s 条相关媒体信息", len(medias)) - request = media_interaction_manager.create_or_replace( - user_id=userid, - channel=channel, - source=source, - username=username, - action=action, - keyword=content, - title=meta.name, - meta=meta, - items=medias, - ) - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - ) - - def _handle_media_selection( - self, - request: PendingMediaInteraction, - page_index: Optional[int], - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 处理媒体选择阶段的序号输入。 - """ - page_items, page, _ = self._page_items( - items=request.items, - page=request.page, - page_size=self._page_size(request.channel), - ) - request.page = page - if not page_index or page_index < 1 or page_index > len(page_items): - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - mediainfo: MediaInfo = page_items[page_index - 1] - request.current_media = mediainfo - - if request.action in {"Search", "ReSearch"}: - self._search_media_resources( - request=request, - mediainfo=mediainfo, - channel=channel, - source=source, - userid=userid, - username=username, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - return - - if request.action in {"Subscribe", "ReSubscribe"}: - self._subscribe_media( - request=request, - mediainfo=mediainfo, - channel=channel, - source=source, - userid=userid, - username=username, - ) - - def _search_media_resources( - self, - request: PendingMediaInteraction, - mediainfo: MediaInfo, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 根据已选媒体搜索资源,并切换到资源选择阶段。 - """ - exist_flag, no_exists = DownloadChain().get_no_exists_info( - meta=request.meta, - mediainfo=mediainfo, - ) - if exist_flag and request.action == "Search": - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】", - save_history=False, - ) - ) - return - if exist_flag: - no_exists = self._get_noexits_info(request.meta, mediainfo) - - messages = self._build_no_exists_messages( - mediainfo=mediainfo, - no_exists=no_exists, - show_missing_only=request.action == "Search", - ) - if messages: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"{mediainfo.title_year}:\n" + "\n".join(messages), - save_history=False, - ) - ) - - logger.info("开始搜索 %s ...", mediainfo.title_year) - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...", - save_history=False, - ) - ) - - contexts = SearchChain().process(mediainfo=mediainfo, no_exists=no_exists) - if not contexts: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"{mediainfo.title}{request.meta.sea} 未搜索到需要的资源!", - save_history=False, - ) - ) - return - - contexts = TorrentHelper().sort_torrents(contexts) - if self._should_auto_download(userid): - logger.info("用户 %s 在自动下载用户中,开始自动择优下载 ...", userid) - request.phase = "torrent" - request.page = 0 - request.title = mediainfo.title - request.items = list(contexts) - if self._prompt_download_dir_selection( - request=request, - download_mode="auto", - channel=channel, - source=source, - userid=userid, - username=username, - no_exists=no_exists, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ): - return - self._auto_download( - request=request, - cache_list=contexts, - channel=channel, - source=source, - userid=userid, - username=username, - no_exists=no_exists, - ) - return - - request.phase = "torrent" - request.page = 0 - request.title = mediainfo.title - request.items = list(contexts) - self._render_interaction( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - - def _subscribe_media( - self, - request: PendingMediaInteraction, - mediainfo: MediaInfo, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 根据已选媒体创建订阅或洗版订阅。 - """ - best_version = request.action == "ReSubscribe" - if not best_version: - exist_flag, _ = DownloadChain().get_no_exists_info( - meta=request.meta, - mediainfo=mediainfo, - ) - if exist_flag: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】", - save_history=False, - ) - ) - return - - mp_name = ( - UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) - if channel - else None - ) - SubscribeChain().add( - title=mediainfo.title, - year=mediainfo.year, - mtype=mediainfo.type, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - season=request.meta.begin_season, - channel=channel, - source=source, - userid=userid, - username=mp_name or username, - best_version=best_version, - ) - - def _handle_torrent_selection( - self, - request: PendingMediaInteraction, - page_index: Optional[int], - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 处理资源选择阶段的下载操作。 - """ - if request.phase != "torrent": - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - if page_index == 0: - if self._prompt_download_dir_selection( - request=request, - download_mode="auto", - channel=channel, - source=source, - userid=userid, - username=username, - ): - return - self._auto_download( - request=request, - cache_list=request.items, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - page_items, page, _ = self._page_items( - items=request.items, - page=request.page, - page_size=self._page_size(request.channel), - ) - request.page = page - if not page_index or page_index < 1 or page_index > len(page_items): - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - context: Context = page_items[page_index - 1] - if self._prompt_download_dir_selection( - request=request, - download_mode="single", - channel=channel, - source=source, - userid=userid, - username=username, - context=context, - ): - return - DownloadChain().download_single( - context, - channel=channel, - source=source, - userid=userid, - username=username, - ) - - def _prompt_download_dir_selection( - self, - request: PendingMediaInteraction, - download_mode: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - context: Optional[Context] = None, - no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> bool: - """ - 在下载前进入目录选择阶段;没有配置下载目录时保持原下载流程。 - """ - media_info = context.media_info if context else request.current_media - download_dirs = self._get_download_dirs(media_info) - if not download_dirs: - return False - if len(download_dirs) == 1 and not self._is_auto_download_dir(download_dirs[0]): - return False - - request.pending_torrent_page = request.page - request.phase = "download-dir" - request.page = 0 - request.download_dirs = download_dirs - request.pending_download_mode = download_mode - request.pending_download_context = context - request.pending_no_exists = no_exists - self._post_download_dirs_message( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - return True - - def _handle_download_dir_selection( - self, - request: PendingMediaInteraction, - page_index: Optional[int], - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 处理下载目录阶段的序号输入,并继续执行挂起的下载动作。 - """ - if request.phase != "download-dir": - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - page_items, page, _ = self._page_items( - items=request.download_dirs, - page=request.page, - page_size=self._page_size(request.channel), - ) - request.page = page - if not page_index or page_index < 1 or page_index > len(page_items): - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - ) - return - - download_dir = page_items[page_index - 1] - if self._is_auto_download_dir(download_dir): - self._execute_pending_download( - request=request, - channel=channel, - source=source, - userid=userid, - username=username, - save_path=None, - ) - return - - save_path = download_dir.save_path or download_dir.download_path - if not save_path: - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="下载目录配置无效!", - ) - return - self._execute_pending_download( - request=request, - channel=channel, - source=source, - userid=userid, - username=username, - save_path=save_path, - ) - - def _execute_pending_download( - self, - request: PendingMediaInteraction, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - save_path: Optional[str], - ) -> None: - """ - 使用用户确认的下载目录执行单资源下载或自动择优下载。 - """ - download_mode = request.pending_download_mode - if download_mode == "single" and request.pending_download_context: - context = request.pending_download_context - self._restore_torrent_phase(request) - DownloadChain().download_single( - context, - channel=channel, - source=source, - userid=userid, - username=username, - save_path=save_path, - ) - return - - if download_mode == "auto": - cache_list = list(request.items or []) - no_exists = request.pending_no_exists - self._restore_torrent_phase(request) - self._auto_download( - request=request, - cache_list=cache_list, - channel=channel, - source=source, - userid=userid, - username=username, - no_exists=no_exists, - save_path=save_path, - ) - return - - self._restore_torrent_phase(request) - self._post_invalid_input( - channel=channel, - source=source, - userid=userid, - username=username, - title="下载操作已失效,请重新选择资源", - ) - - @staticmethod - def _restore_torrent_phase(request: PendingMediaInteraction) -> None: - """ - 下载动作完成或失效后恢复到资源列表阶段,便于用户继续选择其它资源。 - """ - request.phase = "torrent" - request.page = request.pending_torrent_page - request.download_dirs = [] - request.pending_download_mode = None - request.pending_download_context = None - request.pending_no_exists = None - request.pending_torrent_page = 0 - - def _auto_download( - self, - request: PendingMediaInteraction, - cache_list: List[Context], - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None, - save_path: Optional[str] = None, - ) -> None: - """ - 自动择优下载当前资源列表,并在未完成时补建订阅。 - """ - downloadchain = DownloadChain() - if no_exists is None: - exist_flag, no_exists = downloadchain.get_no_exists_info( - meta=request.meta, - mediainfo=request.current_media, - ) - if exist_flag: - no_exists = self._get_noexits_info(request.meta, request.current_media) - - downloads, lefts = downloadchain.batch_download( - contexts=cache_list, - no_exists=no_exists, - save_path=save_path, - channel=channel, - source=source, - userid=userid, - username=username, - ) - if downloads and not lefts: - logger.info("%s 下载完成", request.current_media.title_year) - return - - logger.info("%s 未下载未完整,添加订阅 ...", request.current_media.title_year) - if downloads and request.current_media.type == MediaType.TV: - note = [ - download.meta_info.begin_episode - for download in downloads - if download.meta_info.begin_episode - ] - else: - note = None - - mp_name = ( - UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) - if channel - else None - ) - SubscribeChain().add( - title=request.current_media.title, - year=request.current_media.year, - mtype=request.current_media.type, - media_source=request.current_media.media_source, - media_id=request.current_media.media_id, - season=request.meta.begin_season, - channel=channel, - source=source, - userid=userid, - username=mp_name or username, - state="R", - note=note, - ) - - def _render_interaction( - self, - request: PendingMediaInteraction, - channel: MessageChannel, - source: str, - userid: Union[str, int], - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 按当前阶段渲染媒体列表或资源列表。 - """ - if request.phase == "download-dir": - self._post_download_dirs_message( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - elif request.phase == "torrent": - self._post_torrents_message( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - else: - self._post_medias_message( - request=request, - channel=channel, - source=source, - userid=userid, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - - def _post_medias_message( - self, - request: PendingMediaInteraction, - channel: MessageChannel, - source: str, - userid: Union[str, int], - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 发送或更新媒体选择列表。 - """ - page_items, page, total_pages = self._page_items( - items=request.items, - page=request.page, - page_size=self._page_size(channel), - ) - request.page = page - total = len(request.items) - if self._supports_interactive_buttons(channel): - title = f"【{request.title}】共找到{total}条相关信息,请选择操作" - buttons = self._create_media_buttons( - channel=channel, - request=request, - items=page_items, - total=total, - total_pages=total_pages, - ) - else: - if total > self._page_size(channel): - title = f"【{request.title}】共找到{total}条相关信息,请回复对应数字选择(p: 上一页 n: 下一页)" - else: - title = f"【{request.title}】共找到{total}条相关信息,请回复对应数字选择" - buttons = None - - self.post_medias_message( - Notification( - channel=channel, - source=source, - title=title, - userid=userid, - buttons=buttons, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - save_history=False, - ), - medias=page_items, - ) - - def _post_torrents_message( - self, - request: PendingMediaInteraction, - channel: MessageChannel, - source: str, - userid: Union[str, int], - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 发送或更新资源选择列表。 - """ - page_items, page, total_pages = self._page_items( - items=request.items, - page=request.page, - page_size=self._page_size(channel), - ) - request.page = page - total = len(request.items) - if self._supports_interactive_buttons(channel): - title = f"【{request.title}】共找到{total}条相关资源,请选择下载" - buttons = self._create_torrent_buttons( - channel=channel, - request=request, - items=page_items, - total=total, - total_pages=total_pages, - ) - else: - if total > self._page_size(channel): - title = f"【{request.title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择 p: 上一页 n: 下一页)" - else: - title = f"【{request.title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)" - buttons = None - - self.post_torrents_message( - Notification( - channel=channel, - source=source, - title=title, - userid=userid, - link=settings.MP_DOMAIN("#/resource"), - buttons=buttons, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - save_history=False, - ), - torrents=page_items, - ) - - def _post_download_dirs_message( - self, - request: PendingMediaInteraction, - channel: MessageChannel, - source: str, - userid: Union[str, int], - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> None: - """ - 发送或更新下载目录选择列表。 - """ - page_items, page, total_pages = self._page_items( - items=request.download_dirs, - page=request.page, - page_size=self._page_size(channel), - ) - request.page = page - total = len(request.download_dirs) - if self._supports_interactive_buttons(channel): - title = f"【{request.title}】请选择下载目录" - buttons = self._create_download_dir_buttons( - channel=channel, - request=request, - items=page_items, - total=total, - total_pages=total_pages, - ) - else: - if total > self._page_size(channel): - title = f"【{request.title}】请选择下载目录,请回复对应数字(p: 上一页 n: 下一页)" - else: - title = f"【{request.title}】请选择下载目录,请回复对应数字" - buttons = None - - text = "\n".join( - f"{index}. {self._format_download_dir_label(download_dir)}" - for index, download_dir in enumerate(page_items, start=1) - ) - self.post_message( - Notification( - channel=channel, - source=source, - title=title, - text=text, - userid=userid, - buttons=buttons, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - save_history=False, - ) - ) - - def _create_media_buttons( - self, - channel: MessageChannel, - request: PendingMediaInteraction, - items: List[MediaInfo], - total: int, - total_pages: int, - ) -> List[List[Dict[str, str]]]: - """ - 为媒体列表生成选择和翻页按钮。 - """ - buttons: List[List[Dict[str, str]]] = [] - max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) - max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) - - current_row: List[Dict[str, str]] = [] - for index, media in enumerate(items, start=1): - if max_per_row == 1: - button_text = f"{index}. {media.title_year}" - if len(button_text) > max_text_length: - button_text = button_text[: max_text_length - 3] + "..." - buttons.append( - [ - { - "text": button_text, - "callback_data": f"media:{request.request_id}:select:{index}", - } - ] - ) - continue - - current_row.append( - { - "text": f"{index}", - "callback_data": f"media:{request.request_id}:select:{index}", - } - ) - if len(current_row) == max_per_row or index == len(items): - buttons.append(current_row) - current_row = [] - - if total > self._page_size(channel): - buttons.extend(self._navigation_buttons(request, total_pages)) - return buttons - - def _create_torrent_buttons( - self, - channel: MessageChannel, - request: PendingMediaInteraction, - items: List[Context], - total: int, - total_pages: int, - ) -> List[List[Dict[str, str]]]: - """ - 为资源列表生成下载和翻页按钮。 - """ - buttons: List[List[Dict[str, str]]] = [ - [ - { - "text": "🤖 自动选择下载", - "callback_data": f"media:{request.request_id}:download:0", - } - ] - ] - max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) - max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) - - current_row: List[Dict[str, str]] = [] - for index, context in enumerate(items, start=1): - torrent = context.torrent_info - if max_per_row == 1: - button_text = f"{index}. {torrent.site_name} - {torrent.seeders}↑" - if len(button_text) > max_text_length: - button_text = button_text[: max_text_length - 3] + "..." - buttons.append( - [ - { - "text": button_text, - "callback_data": f"media:{request.request_id}:download:{index}", - } - ] - ) - continue - - current_row.append( - { - "text": f"{index}", - "callback_data": f"media:{request.request_id}:download:{index}", - } - ) - if len(current_row) == max_per_row or index == len(items): - buttons.append(current_row) - current_row = [] - - if total > self._page_size(channel): - buttons.extend(self._navigation_buttons(request, total_pages)) - return buttons - - def _create_download_dir_buttons( - self, - channel: MessageChannel, - request: PendingMediaInteraction, - items: List[DownloadDirectory], - total: int, - total_pages: int, - ) -> List[List[Dict[str, str]]]: - """ - 为下载目录列表生成选择和翻页按钮。 - """ - buttons: List[List[Dict[str, str]]] = [] - max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) - max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) - - current_row: List[Dict[str, str]] = [] - for index, download_dir in enumerate(items, start=1): - if max_per_row == 1: - button_text = f"{index}. {self._format_download_dir_label(download_dir)}" - if len(button_text) > max_text_length: - button_text = button_text[: max_text_length - 3] + "..." - buttons.append( - [ - { - "text": button_text, - "callback_data": f"media:{request.request_id}:download-dir:{index}", - } - ] - ) - continue - - current_row.append( - { - "text": f"{index}", - "callback_data": f"media:{request.request_id}:download-dir:{index}", - } - ) - if len(current_row) == max_per_row or index == len(items): - buttons.append(current_row) - current_row = [] - - if total > self._page_size(channel): - buttons.extend(self._navigation_buttons(request, total_pages)) - return buttons - - def _has_next_page(self, request: PendingMediaInteraction) -> bool: - """ - 判断当前视图是否还有下一页。 - """ - _, page, total_pages = self._page_items( - items=self._get_current_phase_items(request), - page=request.page, - page_size=self._page_size(request.channel), - ) - return page < total_pages - 1 - - @staticmethod - def _get_current_phase_items(request: PendingMediaInteraction) -> List[Any]: - """ - 获取当前阶段用于分页的数据列表。 - """ - if request.phase == "download-dir": - return request.download_dirs - return request.items - - @staticmethod - def _navigation_buttons( - request: PendingMediaInteraction, - total_pages: int, - ) -> List[List[Dict[str, str]]]: - """ - 按当前页状态生成上一页和下一页按钮。 - """ - buttons: List[List[Dict[str, str]]] = [] - nav_row: List[Dict[str, str]] = [] - if request.page > 0: - nav_row.append( - { - "text": "⬅️ 上一页", - "callback_data": f"media:{request.request_id}:page-prev", - } - ) - if request.page < total_pages - 1: - nav_row.append( - { - "text": "下一页 ➡️", - "callback_data": f"media:{request.request_id}:page-next", - } - ) - if nav_row: - buttons.append(nav_row) - return buttons - - @staticmethod - def _page_items( - items: List[Any], - page: int, - page_size: int, - ) -> Tuple[List[Any], int, int]: - """ - 返回当前页数据,并把页码限制在有效范围内。 - """ - total_pages = max(1, math.ceil(len(items) / page_size)) if page_size else 1 - page = min(max(0, page), total_pages - 1) - start = page * page_size - end = start + page_size - return items[start:end], page, total_pages - - @classmethod - def _get_download_dirs(cls, media_info: Optional[MediaInfo] = None) -> List[DownloadDirectory]: - """ - 获取可供消息交互选择的下载目录。 - """ - dir_infos = [ - dir_info - for dir_info in DirectoryHelper().get_download_dirs() - if dir_info.download_path - ] - download_dirs = [ - DownloadDirectory( - name=dir_info.name, - storage=dir_info.storage or "local", - download_path=dir_info.download_path, - save_path=FileURI( - storage=dir_info.storage or "local", - path=dir_info.download_path, - ).uri, - priority=dir_info.priority, - media_type=dir_info.media_type, - media_category=dir_info.media_category, - ) - for dir_info in dir_infos - if cls._match_download_dir_media(dir_info, media_info) - ] - if not download_dirs: - return [] - if len(download_dirs) == 1: - return download_dirs - return [cls._build_auto_download_dir(), *download_dirs] - - @classmethod - def _build_auto_download_dir(cls) -> DownloadDirectory: - """ - 构造自动匹配下载目录选项。 - """ - return DownloadDirectory( - name=cls._auto_download_dir_name, - storage="local", - priority=-1, - ) - - @classmethod - def _is_auto_download_dir(cls, download_dir: DownloadDirectory) -> bool: - """ - 判断是否为自动匹配下载目录选项。 - """ - return ( - download_dir.name == cls._auto_download_dir_name - and not download_dir.download_path - and not download_dir.save_path - ) - - @staticmethod - def _match_download_dir_media( - dir_info: TransferDirectoryConf, - media_info: Optional[MediaInfo], - ) -> bool: - """ - 判断下载目录是否适用于当前媒体。 - """ - if not media_info or not media_info.type: - return True - - if dir_info.media_type: - media_type_values = ( - {media_info.type.value, media_info.type.to_agent()} - if isinstance(media_info.type, MediaType) - else {str(media_info.type)} - ) - if dir_info.media_type not in media_type_values: - return False - - if dir_info.media_category and dir_info.media_category != media_info.category: - return False - - return True - - @staticmethod - def _format_download_dir_label(download_dir: DownloadDirectory) -> str: - """ - 格式化下载目录展示名称,优先显示用户配置的目录名称。 - """ - save_path = download_dir.save_path or download_dir.download_path or "" - name = download_dir.name or save_path or "下载目录" - if save_path and name != save_path: - return f"{name} ({save_path})" - return name - - def _page_size(self, channel: Optional[MessageChannel]) -> int: - """ - 按渠道交互能力选择分页大小。 - """ - return ( - self._button_page_size - if self._supports_interactive_buttons(channel) - else self._text_page_size - ) - - @staticmethod - def _supports_interactive_buttons(channel: Optional[MessageChannel]) -> bool: - """ - 判断渠道是否同时支持按钮展示与按钮回调。 - """ - return bool( - channel - and ChannelCapabilityManager.supports_buttons(channel) - and ChannelCapabilityManager.supports_callbacks(channel) - ) - - @staticmethod - def _build_no_exists_messages( - mediainfo: MediaInfo, - no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]], - show_missing_only: bool, - ) -> List[str]: - """ - 将缺失集信息转换为可发送的文案。 - """ - if not no_exists: - return [] - media_source, media_id = resolve_media_identity(media=mediainfo) - mediakey = build_media_key(media_source, media_id) - season_map = no_exists.get(mediakey) or {} - if show_missing_only: - return [ - f"第 {sea} 季缺失 {episode_rules.compact_numbers(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集" - for sea, no_exist in season_map.items() - ] - return [ - f"第 {sea} 季总 {no_exist.total_episode} 集" - for sea, no_exist in season_map.items() - ] - - @staticmethod - def _should_auto_download(userid: Union[str, int]) -> bool: - """ - 判断当前用户是否命中自动下载名单。 - """ - auto_download_user = settings.AUTO_DOWNLOAD_USER - return bool( - auto_download_user - and ( - auto_download_user == "all" - or any(userid == user for user in auto_download_user.split(",")) - ) - ) - - def _post_invalid_input( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: Optional[str], - title: str = "输入有误!", - ) -> None: - """ - 发送统一的非法输入提示。 - """ - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=title, - save_history=False, - ) - ) diff --git a/app/chain/site.py b/app/chain/site.py index b3efe4e48..5c0246a72 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -17,14 +17,9 @@ from app.adapters.network.browser import PlaywrightHelper from app.adapters.network.cloudflare import under_challenge from app.application.security.cookie import CookieHelper from app.adapters.external.cookiecloud import CookieCloudHelper -from app.application.messaging.interaction import ( - SlashInteractionManager, - build_navigation_buttons, - format_markdown_table, - page_items, - supports_interaction_buttons, - supports_markdown, - update_or_post_message, +from app.application.messaging.site import ( + SiteInteractionHandler, + site_interaction_manager, ) from app.application.rss import RssHelper from app.runtime.log import logger @@ -37,7 +32,6 @@ from app.foundation import size as size_tools from app.foundation import url as url_tools from app.foundation.dom import DomUtils -site_interaction_manager = SlashInteractionManager() class SiteChain(ChainBase): @@ -45,8 +39,6 @@ class SiteChain(ChainBase): 站点管理处理链 """ - _button_page_size = 6 - _text_page_size = 10 def __init__(self): """初始化站点管理处理链及特殊站点测试器""" @@ -756,6 +748,10 @@ class SiteChain(ChainBase): return False, f"无法打开网站!" return True, "连接成功" + def _interaction_handler(self) -> "SiteInteractionHandler": + """构造 /sites 交互处理器,Cookie 更新动作由本链提供。""" + return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie) + def remote_list( self, arg_str: str = "", @@ -764,30 +760,10 @@ class SiteChain(ChainBase): source: Optional[str] = None, ): """ - /sites 统一入口。 + /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="", + return self._interaction_handler().remote_list( + arg_str=arg_str, channel=channel, userid=userid, source=source ) @staticmethod @@ -795,12 +771,7 @@ class SiteChain(ChainBase): """ 解析 /sites 按钮回调。 """ - if not callback_data.startswith("sites:"): - return None - parts = callback_data.split(":") - if len(parts) < 3: - return None - return parts[1], parts[2] + return SiteInteractionHandler.parse_callback(callback_data) def handle_callback_interaction( self, @@ -812,59 +783,9 @@ class SiteChain(ChainBase): 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.post_message( - Notification( - 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, - 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, + """委托交互处理器处理按钮回调。""" + return self._interaction_handler().handle_callback_interaction( + callback_data=callback_data, channel=channel, source=source, userid=userid, @@ -872,7 +793,6 @@ class SiteChain(ChainBase): original_message_id=original_message_id, original_chat_id=original_chat_id, ) - return True def handle_text_interaction( self, @@ -882,424 +802,15 @@ class SiteChain(ChainBase): 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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.post_message( - Notification( - 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: MessageChannel, - 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 = SiteOper().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, + """委托交互处理器处理文本输入。""" + return self._interaction_handler().handle_text_interaction( 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[Site], channel: Optional[MessageChannel] - ) -> 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,请输入: [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 [2fa]`、`启用 `、`禁用 `、" - "`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" - - siteoper = SiteOper() - changed = [] - missing = [] - for site_id in site_ids: - site = siteoper.get(site_id) - if not site: - missing.append(str(site_id)) - continue - siteoper.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 [2fa_code/secret]", - ) - - site_id = int(args[0]) - site_info = SiteOper().get(site_id) - if not site_info: - return False, f"站点编号 {site_id} 不存在" - - status, msg = self.update_cookie( - 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 更新成功" def remote_disable(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 59b9fdcc5..60cb3f923 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -32,14 +32,9 @@ from app.db.models.subscribe import Subscribe from app.db.oper.site import SiteOper from app.db.oper.subscribe import SubscribeOper from app.db.oper.systemconfig import SystemConfigOper -from app.application.messaging.interaction import ( - SlashInteractionManager, - build_navigation_buttons, - format_markdown_table, - page_items, - supports_interaction_buttons, - supports_markdown, - update_or_post_message, +from app.application.messaging.subscribe import ( + SubscribeInteractionHandler, + subscribe_interaction_manager, ) from app.application.mediaserver import MediaServerHelper from app.application.subscribe import add_subscribe, async_add_subscribe @@ -53,7 +48,6 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaS from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity -subscribe_interaction_manager = SlashInteractionManager() def _normalize_music_total_tracks(value: Any) -> Optional[int]: @@ -142,8 +136,6 @@ class SubscribeChain(ChainBase): _rlock = threading.RLock() # 避免莫名原因导致长时间持有锁 _LOCK_TIMOUT = 3600 * 2 - _button_page_size = 6 - _text_page_size = 10 @staticmethod def __normalize_episode_priority(episode_priority: Optional[dict]) -> Dict[str, int]: @@ -3251,6 +3243,10 @@ class SubscribeChain(ChainBase): "season": subscribe.season, }) + def _interaction_handler(self) -> "SubscribeInteractionHandler": + """构造 /subscribes 交互处理器,业务动作由本链提供。""" + return SubscribeInteractionHandler(messenger=self, actions=self) + def remote_list( self, arg_str: str = "", @@ -3259,30 +3255,10 @@ class SubscribeChain(ChainBase): source: Optional[str] = None, ): """ - /subscribes 统一入口。 + /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="", + return self._interaction_handler().remote_list( + arg_str=arg_str, channel=channel, userid=userid, source=source ) @staticmethod @@ -3290,12 +3266,7 @@ class SubscribeChain(ChainBase): """ 解析 /subscribes 按钮回调。 """ - if not callback_data.startswith("subscribes:"): - return None - parts = callback_data.split(":") - if len(parts) < 3: - return None - return parts[1], parts[2] + return SubscribeInteractionHandler.parse_callback(callback_data) def handle_callback_interaction( self, @@ -3307,65 +3278,9 @@ class SubscribeChain(ChainBase): 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.post_message( - schemas.Notification( - 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, - 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, + """委托交互处理器处理按钮回调。""" + return self._interaction_handler().handle_callback_interaction( + callback_data=callback_data, channel=channel, source=source, userid=userid, @@ -3373,7 +3288,6 @@ class SubscribeChain(ChainBase): original_message_id=original_message_id, original_chat_id=original_chat_id, ) - return True def handle_text_interaction( self, @@ -3383,539 +3297,15 @@ class SubscribeChain(ChainBase): 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.post_message( - schemas.Notification( - 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.post_message( - schemas.Notification( - 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.post_message( - schemas.Notification( - 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.post_message( - schemas.Notification( - 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.post_message( - schemas.Notification( - 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.post_message( - schemas.Notification( - 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: MessageChannel, - 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 = SubscribeOper().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, + """委托交互处理器处理文本输入。""" + return self._interaction_handler().handle_text_interaction( 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[Subscribe], channel: Optional[MessageChannel] - ) -> 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: Subscribe) -> 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 ( - "可输入:`搜索 `、`删除 `、`刷新`、`刷新元数据`、`n`、`p`、`退出`。" - ) - - def _run_refresh_action( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 执行订阅刷新。 - """ - self.post_message( - schemas.Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="开始刷新订阅...", - ) - ) - self.refresh() - self.post_message( - schemas.Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="订阅刷新执行完成", - ) - ) - - def _run_metadata_refresh_action( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 执行订阅元数据刷新。 - """ - self.post_message( - schemas.Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="开始刷新订阅元数据...", - ) - ) - self.check() - self.post_message( - schemas.Notification( - 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: MessageChannel, - 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.post_message( - schemas.Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="开始搜索所有订阅...", - ) - ) - self.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" - - subscribeoper = SubscribeOper() - missing = [] - searched = [] - for subscribe_id in subscribe_ids: - subscribe = subscribeoper.get(subscribe_id) - if not subscribe: - missing.append(str(subscribe_id)) - continue - self.post_message( - schemas.Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"开始搜索订阅【{subscribe.name}】...", - ) - ) - self.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" - - subscribeoper = SubscribeOper() - deleted = [] - missing = [] - for subscribe_id in subscribe_ids: - subscribe = subscribeoper.get(subscribe_id) - if not subscribe: - missing.append(str(subscribe_id)) - continue - deleted.append(subscribe.name) - subscribeoper.delete(subscribe_id) - MoviePilotServerHelper.sub_done_async( - { - "media_source": subscribe.media_source, - "media_id": subscribe.media_id, - "season": subscribe.season, - } - ) - - 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 def remote_delete(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): diff --git a/app/chain/transfer.py b/app/chain/transfer.py index f43e888a0..6552ca8ba 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -11,6 +11,7 @@ from typing import List, Optional, Tuple, Union, Dict, Callable, Any from app import schemas from app.agent.orchestrator import ReplyMode, agent_manager, prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain @@ -4480,6 +4481,209 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ return self.__re_transfer(logid=history_id) + @staticmethod + def parse_failed_transfer_callback( + callback_data: str, + ) -> Optional[tuple[str, int]]: + """ + 解析整理失败通知按钮回调。 + """ + for prefix, action in ( + ("transfer_retry_", "retry"), + ("transfer_ai_retry_", "ai_retry"), + ): + if callback_data.startswith(prefix): + history_id = callback_data.replace(prefix, "", 1) + if history_id.isdigit(): + return action, int(history_id) + return None + + def handle_failed_transfer_callback( + self, + *, + callback_data: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> bool: + """ + 处理整理失败通知中的重试类按钮。 + """ + callback = self.parse_failed_transfer_callback(callback_data) + if not callback: + return False + + action, history_id = callback + if action == "retry": + self._retry_transfer_history( + history_id=history_id, + channel=channel, + source=source, + userid=userid, + username=username, + ) + else: + self._take_over_transfer_history_by_ai( + history_id=history_id, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + def _retry_transfer_history( + self, + history_id: int, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 立即重新整理一条失败的整理记录。 + """ + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"开始重新整理记录 #{history_id} ...", + save_history=False, + ) + ) + + state, errmsg = self.redo_transfer_history(history_id) + if state: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"整理记录 #{history_id} 已重新整理", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + return + + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="重新整理失败", + text=errmsg, + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + def _take_over_transfer_history_by_ai( + self, + history_id: int, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 由智能助手接管一条失败的整理记录。 + """ + + if not settings.AI_AGENT_ENABLE: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="MoviePilot智能助手未启用,请在系统设置中启用", + save_history=False, + ) + ) + return + + history = TransferHistoryOper().get(history_id) + if not history: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="重新整理失败", + text=f"整理记录 #{history_id} 不存在", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + return + + redo_prompt = build_manual_redo_prompt(history) + + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"已将整理记录 #{history_id} 交给智能助手处理", + text="处理完成后会在这里回复结果。", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + async def _run_ai_takeover(): + final_output = "" + + def _capture_output(text_output: str): + nonlocal final_output + final_output = text_output or "" + + try: + await agent_manager.run_background_prompt( + message=redo_prompt, + session_prefix=f"__agent_manual_redo_{history_id}", + output_callback=_capture_output, + reply_mode=ReplyMode.CAPTURE_ONLY, + allow_message_tools=False, + ) + await self.async_post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="智能助手整理完成", + text=final_output.strip() + or f"整理记录 #{history_id} 已由智能助手处理完成。", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + except Exception as e: + await self.async_post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="智能助手整理失败", + text=str(e), + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + asyncio.run_coroutine_threadsafe(_run_ai_takeover(), global_vars.loop) + + def __re_transfer( self, logid: int, diff --git a/app/command.py b/app/command.py index 691ae40f2..4211c3bda 100644 --- a/app/command.py +++ b/app/command.py @@ -7,13 +7,13 @@ from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.message import MessageChain from app.chain.site import SiteChain -from app.chain.skills import SkillsChain from app.chain.subscribe import SubscribeChain from app.chain.system import SystemChain from app.chain.transfer import TransferChain from app.runtime.events import Event as ManagerEvent, eventmanager, Event from app.runtime.extensions.plugin_manager import PluginManager from app.application.messaging.message import MessageHelper +from app.application.messaging.skill import SkillInteractionHandler from app.runtime.thread import ThreadHelper from app.runtime.log import logger from app.scheduler import Scheduler @@ -25,7 +25,7 @@ from app.foundation.collections import DictUtils class CommandChain(ChainBase): - pass + """命令分发专用 Chain,仅作为命令侧的消息投递网关。""" def _finish_command_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None: @@ -132,7 +132,7 @@ class Command(metaclass=Singleton): "data": {}, }, "/skills": { - "func": SkillsChain().remote_manage, + "func": SkillInteractionHandler(messenger=CommandChain()).remote_manage, "description": "管理技能", "category": "智能体", "data": {}, diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index 758c0bd58..b16d833b1 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -24,6 +24,12 @@ class SymbolAlias: # 只登记已经删除旧物理源码、并完成 canonical 路径验证的模块。 MODULE_ALIASES: Dict[str, ModuleAlias] = { + "app.chain.media_interaction": ModuleAlias( + target="app.chain.interaction", + replacement="app.chain.interaction", + introduced="v3.0.0", + owner="chain", + ), "app.log": ModuleAlias( target="app.sdk.logging", replacement="app.sdk.logging", @@ -674,6 +680,13 @@ PACKAGE_EXPORTS: Dict[str, Dict[str, SymbolAlias]] = { # 物理模块仍存在、仅部分公开符号迁走时,由导入器在标准 Loader 执行后叠加惰性符号路由。 # canonical 源码不反向依赖兼容层,目标符号也只在旧调用方真正取用时加载。 SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { + "app.chain.message": { + "MediaInteractionChain": SymbolAlias( + target_module="app.chain.interaction", + target_name="MediaInteractionChain", + replacement="app.chain.interaction.MediaInteractionChain", + ), + }, "app.domain.media": { name: SymbolAlias( target_module="app.schemas.media", diff --git a/app/schemas/message.py b/app/schemas/message.py index 601bca0ef..62ffa2e02 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -198,6 +198,8 @@ class CommingMessage(BaseModel): audio_refs: Optional[List[str]] = None # 文件附件列表 files: Optional[List[MessageAttachment]] = None + # 结构化按钮回调数据(优先于 CALLBACK: 文本前缀) + callback_data: Optional[str] = None @field_validator("images", mode="before") @classmethod diff --git a/docs/rules/01-project-overview.md b/docs/rules/01-project-overview.md index 751b8e915..62bb1be5e 100644 --- a/docs/rules/01-project-overview.md +++ b/docs/rules/01-project-overview.md @@ -32,7 +32,7 @@ MoviePilot is a self-hosted media automation platform targeting Chinese-language | `app/runtime/` | Config, events, logging, caching, concurrency, process state, extensions, and legacy compatibility | | `app/adapters/` | Cache, network, system, generated-resource, and named external-product adapters | | `app/runtime/extensions/` | Module, plugin, and configured-service lifecycle management | -| `app/application/messaging/` | Messaging, interaction, and Agent-to-message capabilities | +| `app/application/messaging/` | Messaging, interaction, and Agent-to-message capabilities (`interaction.py` contracts, `router.py` priority/callback dispatch, `site.py`/`subscribe.py`/`skill.py` command sessions, `media.py` media interaction state, `plugin.py` plugin input, `agent.py` agent choice bridge, `message.py` rendering and queue); not a public plugin SDK | | `app/application/security/` | Authentication and access-control capabilities | | `app/application/` | Focused application services | | `app/sdk/` | Stable imports for plugins | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 0034b3451..c33e76fd2 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -62,7 +62,7 @@ to make the directory tree look symmetrical. |---|---| | `app/application/*.py` | Audio, directory, downloader, filter, formatting, transfer history, image, media-server, notification, recognition, RSS, storage and torrent application services | | `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here | -| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge | +| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use | | `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy | Application services may use domain rules, runtime contracts, Oper classes and diff --git a/tests/test_agent_interaction.py b/tests/test_agent_interaction.py index 8dcb16ed9..6f6e7ea84 100644 --- a/tests/test_agent_interaction.py +++ b/tests/test_agent_interaction.py @@ -10,10 +10,11 @@ from app.agent.tools.impl.ask_user_choice import ( UserChoiceOptionInput, ) from app.agent.tools.impl.send_message import SendMessageTool -from app.application.messaging.interaction import ( +from app.application.messaging.agent import ( AgentInteractionOption, agent_interaction_manager, ) +from app.application.messaging.interaction import InteractionContext from app.chain.message import MessageChain from app.runtime.config import settings from app.schemas.types import MessageChannel @@ -201,13 +202,15 @@ class TestAgentInteraction(unittest.TestCase): side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): handled = chain._handle_callback( - text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", - original_message_id=123, - original_chat_id="456", + callback_data=f"agent_interaction:choice:{request.request_id}:1", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + original_message_id=123, + original_chat_id="456", + ), ) self.assertTrue(handled) @@ -246,11 +249,13 @@ class TestAgentInteraction(unittest.TestCase): chain.messagehelper, "put" ), patch.object(chain.messageoper, "add"): chain._handle_callback( - text=f"CALLBACK:agent_choice:{request.request_id}:1", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + callback_data=f"agent_choice:{request.request_id}:1", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), ) handle_ai_message.assert_called_once() @@ -269,9 +274,8 @@ class TestAgentInteraction(unittest.TestCase): chain, "_handle_ai_message", return_value=True, - ) as handle_ai_message, patch.object( - chain, - "_handle_plugin_input_interaction", + ) as handle_ai_message, patch( + "app.chain.message.PluginInputInteractionHandler.handle_text", ) as handle_plugin_interaction, patch.object( chain, "_mark_message_processing_started", diff --git a/tests/test_agent_message_routing.py b/tests/test_agent_message_routing.py index 73a77145c..4eb753f89 100644 --- a/tests/test_agent_message_routing.py +++ b/tests/test_agent_message_routing.py @@ -12,7 +12,9 @@ from app.runtime.config import settings from app.db import SessionFactory from app.db.oper.message import MessageOper from app.db.models.message import Message -from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager +from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager +from app.application.messaging.interaction import InteractionContext +from app.application.messaging.media import media_interaction_manager from app.schemas.types import MessageChannel, NotificationType @@ -39,7 +41,7 @@ def test_explicit_ai_message_bypasses_pending_media_interaction(): try: with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.MediaInteractionChain.handle_text_interaction", + "app.chain.interaction.MediaInteractionChain.handle_text_interaction", return_value=True, ) as handle_media_interaction, patch.object( chain, "_handle_ai_message", return_value=True @@ -264,14 +266,16 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): side_effect=lambda coro, _loop: (coro.close(), Mock())[1], ): chain._handle_callback( - text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", - is_channel_admin=False, - original_message_id=123, - original_chat_id="456", + callback_data=f"agent_interaction:choice:{request.request_id}:1", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + original_message_id=123, + original_chat_id="456", + is_channel_admin=False, + ), ) finally: agent_interaction_manager.clear() diff --git a/tests/test_interaction_router.py b/tests/test_interaction_router.py new file mode 100644 index 000000000..c31118e74 --- /dev/null +++ b/tests/test_interaction_router.py @@ -0,0 +1,193 @@ +"""InteractionRouter 单元测试:会话选择、回调派发顺序和未消费回退语义。""" + +import unittest +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.testing.bootstrap import ensure_optional_stub + +ensure_optional_stub("qbittorrentapi", TorrentFilesList=list) +ensure_optional_stub("transmission_rpc", File=object) +ensure_optional_stub("psutil") +ensure_optional_stub("aioshutil") +ensure_optional_stub("pyquery", PyQuery=object) + +from app.application.messaging.interaction import InteractionContext, InteractionDispatch +from app.application.messaging.router import ( + CallbackRoute, + InteractionRouter, + SessionRoute, + has_pending_interaction, +) +from app.application.messaging.site import site_interaction_manager +from app.application.messaging.skill import skill_interaction_manager +from app.schemas.types import MessageChannel + + +def _context(user_id="10001") -> InteractionContext: + """构造最小交互上下文。""" + return InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id=user_id, + username="tester", + ) + + +def _session_route(name: str, pending=None, consumed=True) -> tuple[SessionRoute, MagicMock]: + """构造带可控返回值的会话路由,同时返回 handler 便于断言。""" + handler = MagicMock(return_value=consumed) + route = SessionRoute( + name=name, + get_pending=lambda _user_id, _pending=pending: _pending, + handle_text=handler, + ) + return route, handler + + +def _callback_route(name: str, matched=True, handled=True) -> CallbackRoute: + """构造带可控匹配和处理结果的回调路由。""" + dispatcher = MagicMock(return_value=InteractionDispatch(handled=handled)) + return CallbackRoute( + name=name, + matches=lambda _data, _matched=matched: _matched, + dispatch=dispatcher, + ) + + +class TestInteractionRouterSessions(unittest.TestCase): + def test_latest_session_prefers_newest_created_at(self): + """多个待处理会话时选择创建时间最近的一条。""" + now = datetime.now() + old_route, _ = _session_route( + "sites", pending=SimpleNamespace(created_at=now - timedelta(minutes=10)) + ) + new_route, _ = _session_route( + "media", pending=SimpleNamespace(created_at=now) + ) + router = InteractionRouter( + session_routes=[old_route, new_route], callback_routes=[] + ) + + self.assertEqual(router.latest_session("10001"), new_route) + + def test_latest_session_missing_timestamp_treated_as_oldest(self): + """缺少时间戳的会话不应抢占有时间戳的会话。""" + plain_route, _ = _session_route("sites", pending=SimpleNamespace()) + stamped_route, _ = _session_route( + "media", pending=SimpleNamespace(created_at=datetime.now()) + ) + router = InteractionRouter( + session_routes=[plain_route, stamped_route], callback_routes=[] + ) + + self.assertEqual(router.latest_session("10001"), stamped_route) + + def test_dispatch_active_text_consumed_by_latest_session(self): + """文本应只派发给最近会话并返回其消费结果。""" + old_route, old_handler = _session_route( + "sites", pending=SimpleNamespace(created_at=None) + ) + new_route, new_handler = _session_route( + "media", pending=SimpleNamespace(created_at=datetime.now()) + ) + router = InteractionRouter( + session_routes=[old_route, new_route], callback_routes=[] + ) + + self.assertTrue(router.dispatch_active_text(_context(), "输入内容")) + new_handler.assert_called_once() + old_handler.assert_not_called() + + def test_dispatch_active_text_returns_false_without_session(self): + """没有待处理会话时不消费文本。""" + router = InteractionRouter( + session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[] + ) + + self.assertFalse(router.dispatch_active_text(_context(), "输入内容")) + + def test_has_pending_checks_all_routes(self): + """任意路由存在待处理会话即视为有待处理交互。""" + router = InteractionRouter( + session_routes=[ + _session_route("sites", pending=None)[0], + _session_route("media", pending=SimpleNamespace())[0], + ], + callback_routes=[], + ) + + self.assertTrue(router.has_pending("10001")) + empty_router = InteractionRouter( + session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[] + ) + self.assertFalse(empty_router.has_pending("10001")) + + +class TestInteractionRouterCallbacks(unittest.TestCase): + def test_dispatch_callback_respects_registration_order(self): + """回调按注册顺序匹配,首个匹配并消费的路由生效。""" + first = _callback_route("transfer", matched=True, handled=True) + second = _callback_route("skill", matched=True, handled=True) + router = InteractionRouter( + session_routes=[], callback_routes=[first, second] + ) + + result = router.dispatch_callback(_context(), "any") + + self.assertTrue(result.handled) + first.dispatch.assert_called_once() + second.dispatch.assert_not_called() + + def test_dispatch_callback_continues_when_matched_route_not_handled(self): + """匹配但未消费的路由不拦截后续路由。""" + unmatched = _callback_route("transfer", matched=False, handled=True) + skipped = _callback_route("skill", matched=True, handled=False) + consumer = _callback_route("site", matched=True, handled=True) + router = InteractionRouter( + session_routes=[], callback_routes=[unmatched, skipped, consumer] + ) + + result = router.dispatch_callback(_context(), "any") + + self.assertTrue(result.handled) + unmatched.dispatch.assert_not_called() + skipped.dispatch.assert_called_once() + consumer.dispatch.assert_called_once() + + def test_dispatch_callback_unhandled_when_no_route_matches(self): + """所有路由均不匹配时返回未处理。""" + router = InteractionRouter( + session_routes=[], + callback_routes=[_callback_route("transfer", matched=False)], + ) + + result = router.dispatch_callback(_context(), "unknown") + + self.assertFalse(result.handled) + self.assertFalse(result.defer_processing_finish) + + +class TestHasPendingInteraction(unittest.TestCase): + def tearDown(self): + site_interaction_manager.clear() + skill_interaction_manager.clear() + + def test_has_pending_interaction_detects_real_sessions(self): + """WebAgent 判断应覆盖真实交互会话管理器。""" + self.assertFalse(has_pending_interaction("10001")) + + site_interaction_manager.create_or_replace( + user_id="10001", + command="/sites", + channel=MessageChannel.Telegram, + source="telegram-test", + username="tester", + ) + self.assertTrue(has_pending_interaction("10001")) + self.assertFalse(has_pending_interaction("10002")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index b84c5a35c..fcdbb1914 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -3,11 +3,17 @@ from unittest.mock import patch import pytest -from app.chain.message import MediaInteractionChain, MessageChain +from app.chain.message import MessageChain +from app.chain.interaction import MediaInteractionChain from app.runtime.events import EventManager from app.domain.context import Context, MediaInfo, TorrentInfo from app.domain.meta.metabase import MetaBase -from app.application.messaging.interaction import media_interaction_manager, plugin_input_interaction_manager +from app.application.messaging.interaction import InteractionContext +from app.application.messaging.media import media_interaction_manager +from app.application.messaging.plugin import ( + PluginInputInteractionHandler, + plugin_input_interaction_manager, +) from app.schemas import CommingMessage, TransferDirectoryConf from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel @@ -175,7 +181,7 @@ def test_message_routes_text_reply_to_media_interaction_before_ai(): assert request is not None with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.MediaInteractionChain.handle_text_interaction", + "app.chain.interaction.MediaInteractionChain.handle_text_interaction", return_value=True, ) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai: chain.handle_message( @@ -270,8 +276,9 @@ def test_handle_message_keeps_legacy_positional_images_argument(): chain = MessageChain() images = [CommingMessage.MessageImage(ref="tg://file_id/photo-1")] - with patch.object( - chain, "_handle_plugin_input_interaction", return_value=False + with patch( + "app.chain.message.PluginInputInteractionHandler.handle_text", + return_value=False, ), patch.object( chain, "_mark_message_processing_started", return_value=None ), patch.object( @@ -319,7 +326,7 @@ def test_plugin_input_session_captures_plain_text_before_media_interaction(): ) with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.MediaInteractionChain.handle_text_interaction", + "app.chain.interaction.MediaInteractionChain.handle_text_interaction", return_value=True, ) as handle_media, patch.object(chain.eventmanager, "send_event") as send_event: chain.handle_message( @@ -518,11 +525,13 @@ def test_plugin_input_session_ignores_none_text_messages(): ) image = CommingMessage.MessageImage(ref="https://example.invalid/image.jpg") - handled = chain._handle_plugin_input_interaction( - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + handled = PluginInputInteractionHandler(messenger=chain).handle_text( + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), text=None, images=[image], ) @@ -1547,7 +1556,7 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled(): "app.chain.media.MediaChain.search", return_value=(meta, medias), ) as search_media, patch( - "app.chain.message.MediaInteractionChain.post_medias_message" + "app.chain.interaction.MediaInteractionChain.post_medias_message" ) as post_medias_message, patch.object( chain, "_handle_ai_message" ) as handle_ai: @@ -1591,7 +1600,7 @@ def test_noai_prefix_preserves_traditional_interaction_priority_after_search(): ), patch( "app.chain.message.settings.AI_AGENT_GLOBAL", True ), patch( - "app.chain.message.MediaInteractionChain.handle_text_interaction", + "app.chain.interaction.MediaInteractionChain.handle_text_interaction", return_value=True, ) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai: chain.handle_message( @@ -1622,15 +1631,17 @@ def test_callback_routes_to_media_interaction_chain(): ) with patch( - "app.chain.message.MediaInteractionChain.handle_callback_interaction", + "app.chain.interaction.MediaInteractionChain.handle_callback_interaction", return_value=True, ) as handle_callback: chain._handle_callback( - text=f"CALLBACK:media:{request.request_id}:page-next", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + callback_data=f"media:{request.request_id}:page-next", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), ) handle_callback.assert_called_once() diff --git a/tests/test_skills_command.py b/tests/test_skills_command.py index bc4389c1e..42f3751a0 100644 --- a/tests/test_skills_command.py +++ b/tests/test_skills_command.py @@ -14,7 +14,9 @@ ensure_optional_stub("aioshutil") ensure_optional_stub("pyquery", PyQuery=object) from app.chain.message import MessageChain -from app.chain.skills import SkillsChain, skills_interaction_manager +from app.application.messaging.interaction import InteractionContext +from app.application.messaging.skill import SkillInteractionHandler +from app.application.messaging.skill import skill_interaction_manager from app.agent.skills.registry import ( SkillHelper, SkillInfo, @@ -54,11 +56,11 @@ class _FakeResponse: class TestSkillsCommand(unittest.TestCase): def tearDown(self): - skills_interaction_manager.clear() + skill_interaction_manager.clear() def test_message_routes_text_reply_to_skills_interaction_before_ai(self): chain = MessageChain() - skills_interaction_manager.create_or_replace( + skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Wechat, source="wechat-test", @@ -66,7 +68,7 @@ class TestSkillsCommand(unittest.TestCase): ) with patch.object(chain, "_record_user_message"), patch( - "app.chain.message.SkillsChain.handle_text_interaction", + "app.chain.message.SkillInteractionHandler.handle_text_interaction", return_value=True, ) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai: chain.handle_message( @@ -81,15 +83,15 @@ class TestSkillsCommand(unittest.TestCase): handle_ai.assert_not_called() def test_skills_text_exit_skips_notification_history(self): - chain = SkillsChain() - skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", username="tester", ) - with patch.object(chain, "post_message") as post_message: + with patch.object(chain._messenger, "post_message") as post_message: handled = chain.handle_text_interaction( channel=MessageChannel.Telegram, source="telegram-test", @@ -102,11 +104,11 @@ class TestSkillsCommand(unittest.TestCase): notification = post_message.call_args.args[0] self.assertEqual(notification.title, "技能交互已结束") self.assertFalse(notification.save_history) - self.assertIsNone(skills_interaction_manager.get_by_user("10001")) + self.assertIsNone(skill_interaction_manager.get_by_user("10001")) def test_callback_routes_to_skills_chain(self): chain = MessageChain() - request = skills_interaction_manager.create_or_replace( + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -114,15 +116,17 @@ class TestSkillsCommand(unittest.TestCase): ) with patch( - "app.chain.message.SkillsChain.handle_callback_interaction", + "app.chain.message.SkillInteractionHandler.handle_callback_interaction", return_value=True, ) as handle_callback: chain._handle_callback( - text=f"CALLBACK:skills:{request.request_id}:market", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + callback_data=f"skills:{request.request_id}:market", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), ) handle_callback.assert_called_once() @@ -383,8 +387,8 @@ class TestSkillsCommand(unittest.TestCase): self.assertIn("内置默认源", message) def test_skills_chain_market_view_marks_clawhub_as_community_source(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -416,8 +420,8 @@ class TestSkillsCommand(unittest.TestCase): self.assertIn("ClawHub 属于社区注册表", text) def test_skills_chain_market_view_filters_by_search_query(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -460,8 +464,8 @@ class TestSkillsCommand(unittest.TestCase): self.assertEqual(buttons[0][0]["callback_data"], f"skills:{request.request_id}:clear-search") def test_skills_chain_root_view_uses_friendly_source_labels(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -503,8 +507,8 @@ class TestSkillsCommand(unittest.TestCase): self.assertIn("3. 管理技能源", text) def test_skills_chain_installed_view_builds_remove_buttons(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.WebAgent, source="web-agent", @@ -545,8 +549,8 @@ class TestSkillsCommand(unittest.TestCase): ) def test_skills_chain_callback_enters_search_input_mode(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -568,8 +572,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_text_search_updates_market_query(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -593,8 +597,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_followup_text_applies_search_when_awaiting_input(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -618,8 +622,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_callback_enters_source_add_mode(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -641,8 +645,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_followup_text_adds_custom_market_source(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -656,7 +660,7 @@ class TestSkillsCommand(unittest.TestCase): "add_custom_market_source", return_value=(True, "已添加技能源:仓库来源 · acme/custom-skills"), ) as add_source, patch.object(chain, "_render_interaction") as render, patch.object( - chain, "post_message" + chain._messenger, "post_message" ) as post_message: handled = chain.handle_text_interaction( channel=MessageChannel.Telegram, @@ -673,8 +677,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_text_removes_custom_market_source_by_index(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -686,7 +690,7 @@ class TestSkillsCommand(unittest.TestCase): "_remove_market_source", return_value=(True, "已删除技能源:仓库来源 · acme/custom-skills"), ) as remove_source, patch.object(chain, "_render_interaction") as render, patch.object( - chain, "post_message" + chain._messenger, "post_message" ) as post_message: handled = chain.handle_text_interaction( channel=MessageChannel.Telegram, @@ -703,8 +707,8 @@ class TestSkillsCommand(unittest.TestCase): render.assert_called_once() def test_skills_chain_source_view_lists_custom_sources(self): - chain = SkillsChain() - request = skills_interaction_manager.create_or_replace( + chain = SkillInteractionHandler(messenger=MessageChain()) + request = skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Telegram, source="telegram-test", @@ -743,11 +747,11 @@ class TestSkillsCommand(unittest.TestCase): ) def test_skills_chain_updates_buttons_via_edit_message(self): - chain = SkillsChain() + chain = SkillInteractionHandler(messenger=MessageChain()) buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]] - with patch.object(chain, "edit_message", return_value=True) as edit_message, patch.object( - chain, "post_message" + with patch.object(chain._messenger, "edit_message", return_value=True) as edit_message, patch.object( + chain._messenger, "post_message" ) as post_message: chain._update_or_post_message( channel=MessageChannel.Telegram, diff --git a/tests/test_slash_command_interactions.py b/tests/test_slash_command_interactions.py index a431c3a73..0602c0295 100644 --- a/tests/test_slash_command_interactions.py +++ b/tests/test_slash_command_interactions.py @@ -11,21 +11,22 @@ ensure_optional_stub("aioshutil") ensure_optional_stub("pyquery", PyQuery=object) from app.chain.message import MessageChain +from app.application.messaging.interaction import InteractionContext from app.chain.site import SiteChain, site_interaction_manager -from app.chain.skills import skills_interaction_manager +from app.application.messaging.skill import skill_interaction_manager from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager from app.schemas.types import MessageChannel class TestSlashCommandInteractions(unittest.TestCase): def tearDown(self): - skills_interaction_manager.clear() + skill_interaction_manager.clear() site_interaction_manager.clear() subscribe_interaction_manager.clear() def test_message_routes_text_reply_to_latest_sites_interaction(self): chain = MessageChain() - skills_interaction_manager.create_or_replace( + skill_interaction_manager.create_or_replace( user_id="10001", channel=MessageChannel.Wechat, source="wechat-test", @@ -43,7 +44,7 @@ class TestSlashCommandInteractions(unittest.TestCase): "app.chain.message.SiteChain.handle_text_interaction", return_value=True, ) as handle_site, patch( - "app.chain.message.SkillsChain.handle_text_interaction" + "app.chain.message.SkillInteractionHandler.handle_text_interaction" ) as handle_skills: chain.handle_message( channel=MessageChannel.Wechat, @@ -105,11 +106,13 @@ class TestSlashCommandInteractions(unittest.TestCase): return_value=True, ) as handle_callback: chain._handle_callback( - text=f"CALLBACK:sites:{request.request_id}:refresh", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + callback_data=f"sites:{request.request_id}:refresh", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), ) handle_callback.assert_called_once() @@ -129,11 +132,13 @@ class TestSlashCommandInteractions(unittest.TestCase): return_value=True, ) as handle_callback: chain._handle_callback( - text=f"CALLBACK:subscribes:{request.request_id}:refresh", - channel=MessageChannel.Telegram, - source="telegram-test", - userid="10001", - username="tester", + callback_data=f"subscribes:{request.request_id}:refresh", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), ) handle_callback.assert_called_once() diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index f5fafa83f..bbe8749cb 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -399,6 +399,8 @@ def _load_subscribe_chain_class(): SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class() +# 进度格式化已迁移到交互处理器,经由隔离加载的模块获取 +SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler def _patch_media_recognize(module, result): @@ -473,7 +475,7 @@ class SubscribeChainTest(TestCase): """订阅列表展示必须把 S0 当作合法季号,而不是回退到第 1 季。""" subscribe = self._build_subscribe(season=0, total_episode=5, lack_episode=2) - progress = SubscribeChain._format_subscribe_progress(subscribe) + progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe) self.assertEqual(progress, "第0季 [3/5]") @@ -481,7 +483,7 @@ class SubscribeChainTest(TestCase): """S0 没有总集数时仍显示特别季季号。""" subscribe = self._build_subscribe(season=0, total_episode=None, lack_episode=None) - progress = SubscribeChain._format_subscribe_progress(subscribe) + progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe) self.assertEqual(progress, "第0季") diff --git a/tests/test_transfer_failed_retry_buttons.py b/tests/test_transfer_failed_retry_buttons.py index 084de5efa..495be8b57 100644 --- a/tests/test_transfer_failed_retry_buttons.py +++ b/tests/test_transfer_failed_retry_buttons.py @@ -13,6 +13,7 @@ sys.modules.setdefault("psutil", ModuleType("psutil")) from app.chain.message import MessageChain from app.chain.transfer import TransferChain +from app.application.messaging.interaction import InteractionContext from app.runtime.config import settings from app.schemas.types import MessageChannel @@ -49,21 +50,45 @@ class TestTransferFailedRetryButtons(unittest.TestCase): redo.assert_called_once_with(12) post_message.assert_not_called() - def test_transfer_retry_callback_retries_history(self): + def test_message_chain_routes_transfer_callback_to_transfer_chain(self): + """MessageChain 收到整理失败按钮回调时委托 TransferChain 处理。""" chain = MessageChain() with patch("app.chain.message.TransferChain") as transfer_cls: - transfer_cls.return_value.redo_transfer_history.return_value = (True, "") + transfer_cls.return_value.handle_failed_transfer_callback.return_value = True + chain._handle_callback( + callback_data="transfer_retry_12", + context=InteractionContext( + channel=MessageChannel.Telegram, + source="telegram-test", + user_id="10001", + username="tester", + ), + ) + + transfer_cls.return_value.handle_failed_transfer_callback.assert_called_once_with( + callback_data="transfer_retry_12", + channel=MessageChannel.Telegram, + source="telegram-test", + userid="10001", + username="tester", + ) + + def test_transfer_retry_callback_retries_history(self): + chain = TransferChain() + + with patch.object(chain, "redo_transfer_history", return_value=(True, "")) as redo: with patch.object(chain, "post_message") as post_message: - chain._handle_callback( - text="CALLBACK:transfer_retry_12", + handled = chain.handle_failed_transfer_callback( + callback_data="transfer_retry_12", channel=MessageChannel.Telegram, source="telegram-test", userid="10001", username="tester", ) - transfer_cls.return_value.redo_transfer_history.assert_called_once_with(12) + self.assertTrue(handled) + redo.assert_called_once_with(12) self.assertEqual(post_message.call_count, 2) self.assertEqual( post_message.call_args_list[0].args[0].title, @@ -75,7 +100,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase): ) def test_transfer_ai_retry_callback_schedules_agent_takeover(self): - chain = MessageChain() + chain = TransferChain() history = SimpleNamespace( id=34, status=False, @@ -107,15 +132,15 @@ class TestTransferFailedRetryButtons(unittest.TestCase): with patch.object(settings, "AI_AGENT_ENABLE", True): with patch( - "app.chain.message.TransferHistoryOper" + "app.chain.transfer.TransferHistoryOper" ) as history_oper_cls, patch( - "app.chain.message.asyncio.run_coroutine_threadsafe", + "app.chain.transfer.asyncio.run_coroutine_threadsafe", side_effect=_close_pending_coro, ) as run_task: history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message") as post_message: - chain._handle_callback( - text="CALLBACK:transfer_ai_retry_34", + chain.handle_failed_transfer_callback( + callback_data="transfer_ai_retry_34", channel=MessageChannel.Telegram, source="telegram-test", userid="10001", @@ -130,7 +155,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase): ) def test_transfer_ai_retry_callback_uses_successful_move_dest_as_source(self): - chain = MessageChain() + chain = TransferChain() captured = {} history = SimpleNamespace( id=35, @@ -177,20 +202,20 @@ class TestTransferFailedRetryButtons(unittest.TestCase): with patch.object(settings, "AI_AGENT_ENABLE", True): with patch( - "app.chain.message.TransferHistoryOper" + "app.chain.transfer.TransferHistoryOper" ) as history_oper_cls, patch( - "app.chain.message.agent_manager.run_background_prompt", + "app.chain.transfer.agent_manager.run_background_prompt", side_effect=fake_run_background_prompt, ), patch( - "app.chain.message.asyncio.run_coroutine_threadsafe", + "app.chain.transfer.asyncio.run_coroutine_threadsafe", side_effect=_run_pending_coro, ): history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message"), patch.object( chain, "async_post_message", side_effect=fake_async_post_message ): - chain._handle_callback( - text="CALLBACK:transfer_ai_retry_35", + chain.handle_failed_transfer_callback( + callback_data="transfer_ai_retry_35", channel=MessageChannel.Telegram, source="telegram-test", userid="10001", diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index 8d7102385..e908cb561 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -34,7 +34,8 @@ from app.runtime.events import Event from app.db.oper.agentchat import AgentChatOper from app.db.models.agentchat import AgentChat from app.application.messaging.agent import build_web_agent_message_update_event -from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, skills_interaction_manager +from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager +from app.application.messaging.skill import skill_interaction_manager from app.chain.message import MessageChain from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.types import EventType, MessageChannel, NotificationType @@ -340,9 +341,9 @@ def test_build_web_agent_display_message_from_events_marks_done(): def test_has_web_agent_traditional_interaction_detects_pending_skills(): """WebAgent 应能识别命令后的传统交互上下文。""" - skills_interaction_manager.clear() + skill_interaction_manager.clear() try: - skills_interaction_manager.create_or_replace( + skill_interaction_manager.create_or_replace( user_id="1", channel=MessageChannel.WebAgent, source="web-agent", @@ -352,7 +353,7 @@ def test_has_web_agent_traditional_interaction_detects_pending_skills(): assert _has_web_agent_traditional_interaction("1") is True assert _has_web_agent_traditional_interaction("2") is False finally: - skills_interaction_manager.clear() + skill_interaction_manager.clear() def test_web_agent_admin_context_uses_current_user_id():