Files
DDSRem e0642eafa7 chore(types): reduce v3 host mypy debt (#7057)
* chore(types): clear AniList endpoint mypy debt

* chore(types): clear Douban endpoint mypy debt

* chore(types): clear recommendation endpoint mypy debt

* chore(types): clear subscription notification mypy debt

* chore(types): clear SDK string compatibility mypy debt

* chore(types): ratchet v3 host mypy across remaining files

* chore(types): reduce v3 host mypy debt and retire ledger

* chore(types): reconcile host snapshot with upstream HTTP pool

* test(types): cover host paths exposed by mypy cleanup
2026-10-18 09:37:49 +08:00

413 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
渠道交互的通用状态存储、消息端口与按钮展示工具。
"""
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):
"""
声明按用户维护的交互状态所需的最小字段。
:ivar request_id: 当前交互请求标识,用于校验按钮是否仍属于活动会话
:ivar user_id: 所属用户标识,用于隔离不同用户的交互状态
:ivar created_at: 会话创建的本地时间,用于计算过期时间
"""
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: Optional[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 命令交互上下文。
:param request_id: 当前交互请求标识,用于校验按钮是否仍属于活动会话
:param user_id: 所属用户标识,用于隔离不同用户的交互状态
:param channel: 回复使用的通知渠道,None 表示未指定
:param source: 渠道实例标识,None 表示未指定实例
:param username: 用户显示名称,未知时为 None
:param command: 当前处理的斜杠命令名称
:param page: 当前列表页索引,从 0 开始
:param awaiting_input: 当前等待的文本输入用途,None 表示未等待补充输入
:param created_at: 会话创建的本地时间,用于计算过期时间
"""
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: Optional[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:
"""
描述一次与渠道无关的用户交互上下文。
:param channel: 接收本次交互的通知渠道
:param source: 渠道实例标识,None 表示未指定实例
:param user_id: 所属用户标识,用于隔离不同用户的交互状态
:param username: 用户显示名称,未知时为 None
:param original_message_id: 渠道原始消息标识,未知时为 None
:param original_chat_id: 渠道原始聊天标识,未知时为 None
:param is_channel_admin: 渠道管理员身份判断;None 表示尚未判定
"""
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:
"""
描述交互路由是否命中以及是否延迟结束处理状态。
:param handled: 是否已由本次路由消费交互
:param defer_processing_finish: 是否由后续异步流程结束渠道处理状态,默认立即结束
"""
handled: bool
defer_processing_finish: bool = False
class MessageGateway(Protocol):
"""
声明交互控制器使用的消息发送和编辑能力。
"""
def post_message(self, message: Message) -> None:
"""
发送一条交互消息。
:param message: 包含渠道、接收用户和展示内容的消息
"""
...
def edit_message(
self,
channel: NotificationChannel,
source: Optional[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:
"""
编辑一条已发送的交互消息。
:param channel: 原消息所属通知渠道
:param source: 原消息所属渠道实例标识
:param message_id: 渠道分配的原消息标识
:param chat_id: 原消息所在的聊天标识
:param text: 替换后的消息正文
:param title: 替换后的标题,None 表示不提供独立标题
:param buttons: 按行组织的新按钮,None 表示不提供按钮
:param metadata: 渠道特定的附加编辑参数,None 表示无附加参数
:return: 渠道确认编辑成功时为 True;失败或不支持编辑时为 False
"""
...
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: _UserInteractionEntry,
page: int,
total_pages: int,
) -> List[List[dict[str, str]]]:
"""
构造标准上一页/下一页按钮。
"""
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: MessageGateway,
channel: Optional[NotificationChannel],
source: Optional[str],
userid: Optional[Union[str, int]],
username: Optional[str],
title: str,
text: str,
buttons: Optional[List[List[dict[str, str]]]] = 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 channel is not None
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])