Files
MoviePilot/app/application/messaging/interaction/__init__.py

341 lines
9.9 KiB
Python

import math
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import (
Any,
Dict,
Generic,
List,
Optional,
Protocol,
Sequence,
Tuple,
TypeVar,
Union,
)
from app.schemas.message import Message
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
class _UserInteractionEntry(Protocol):
"""声明按用户维护的交互状态所需的最小字段。"""
request_id: str
user_id: str
created_at: datetime
InteractionEntryT = TypeVar("InteractionEntryT", bound=_UserInteractionEntry)
class _ExpiringUserInteractionStore(Generic[InteractionEntryT]):
"""提供单用户单会话的过期状态存储和双向索引。"""
_ttl = timedelta(hours=24)
def __init__(self) -> None:
"""初始化按请求 ID、用户 ID 查询的线程安全状态表。"""
self._by_id: Dict[str, InteractionEntryT] = {}
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 _replace_locked(self, request: InteractionEntryT) -> None:
"""在持锁状态下替换用户旧状态并写入新的双向索引。"""
user_key = str(request.user_id)
old_request_id = self._by_user.get(user_key)
if old_request_id:
self._by_id.pop(old_request_id, None)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
def get_by_user(self, user_id: Union[str, int]) -> Optional[InteractionEntryT]:
"""按用户返回当前仍有效的交互状态。"""
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[InteractionEntryT]:
"""按请求 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()
@dataclass
class PendingSlashInteraction:
"""
通用 slash 命令交互上下文。
"""
request_id: str
user_id: str
channel: Optional[NotificationChannel]
source: Optional[str]
username: Optional[str]
command: str
page: int = 0
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
class SlashInteractionManager(
_ExpiringUserInteractionStore[PendingSlashInteraction]
):
"""
管理单个 slash 命令的交互会话。
"""
def create_or_replace(
self,
user_id: Union[str, int],
command: str,
channel: Optional[NotificationChannel],
source: Optional[str],
username: Optional[str],
) -> PendingSlashInteraction:
"""为用户创建 slash 会话并替换其上一条会话。"""
with self._lock:
self._cleanup_locked()
user_key = str(user_id)
request = PendingSlashInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
command=command,
channel=channel,
source=source,
username=username,
)
self._replace_locked(request)
return request
@dataclass(frozen=True, slots=True)
class InteractionContext:
"""描述一次与渠道无关的用户交互上下文。"""
channel: NotificationChannel
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: Message) -> None:
"""发送一条交互消息。"""
...
def edit_message(
self,
channel: NotificationChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
buttons: Optional[List[List[Dict[str, Any]]]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""编辑一条已发送的交互消息。"""
...
def supports_interaction_buttons(channel: Optional[NotificationChannel]) -> bool:
"""
渠道同时支持按钮和回调时,优先使用按钮交互。
"""
return bool(
channel
and ChannelCapabilityManager.supports_buttons(channel)
and ChannelCapabilityManager.supports_callbacks(channel)
)
def supports_markdown(channel: Optional[NotificationChannel]) -> bool:
"""
仅在支持 Markdown 的渠道上输出 Markdown 内容。
"""
return bool(channel and ChannelCapabilityManager.supports_markdown(channel))
def page_items(
items: Sequence[Any],
page: int,
page_size: int,
) -> Tuple[List[Any], int, int]:
"""
对列表做分页并规范化页码。
"""
total = len(items)
if total == 0:
return [], 0, 1
total_pages = max(1, math.ceil(total / max(1, page_size)))
page = min(max(0, page), total_pages - 1)
start = page * page_size
end = start + page_size
return list(items[start:end]), page, total_pages
def build_navigation_buttons(
prefix: str,
request: Any,
page: int,
total_pages: int,
) -> List[List[dict]]:
"""
构造标准上一页/下一页按钮。
"""
buttons = []
nav_row = []
if page > 0:
nav_row.append(
{
"text": "⬅️ 上一页",
"callback_data": f"{prefix}:{request.request_id}:page-prev",
}
)
if page < total_pages - 1:
nav_row.append(
{
"text": "下一页 ➡️",
"callback_data": f"{prefix}:{request.request_id}:page-next",
}
)
if nav_row:
buttons.append(nav_row)
return buttons
def update_or_post_message(
chain,
channel: NotificationChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
title: str,
text: str,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""
优先编辑原消息,失败时回退为发送新消息。
"""
if (
original_message_id
and original_chat_id
and ChannelCapabilityManager.supports_editing(channel)
):
edit_kwargs = {}
if channel == NotificationChannel.WebAgent:
edit_kwargs["metadata"] = {"userid": userid}
edited = chain.edit_message(
channel=channel,
source=source,
message_id=original_message_id,
chat_id=original_chat_id,
title=title,
text=text,
buttons=buttons,
**edit_kwargs,
)
if edited:
return
chain.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title=title,
text=text,
buttons=buttons,
# 编辑失败回退发新消息时保留原消息上下文,
# 保证飞书等渠道能回复到原会话(如群聊),而不是发给私聊。
original_message_id=original_message_id,
original_chat_id=original_chat_id,
save_history=False,
)
)
def escape_markdown_table_cell(value: object) -> str:
"""
最小化转义 Markdown 表格中的特殊字符。
"""
text = str(value or "").replace("\n", "<br>")
return text.replace("|", "\\|")
def format_markdown_table(
headers: Sequence[str],
rows: Sequence[Sequence[object]],
) -> str:
"""
生成 Markdown 表格文本。
"""
header_line = (
"| "
+ " | ".join(escape_markdown_table_cell(item) for item in headers)
+ " |"
)
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
data_lines = [
"| "
+ " | ".join(escape_markdown_table_cell(item) for item in row)
+ " |"
for row in rows
]
return "\n".join([header_line, separator_line, *data_lines])