mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-10 23:11:47 +08:00
refactor: reorganize interaction chain
This commit is contained in:
@@ -1 +0,0 @@
|
||||
from .cloudflare import under_challenge
|
||||
|
||||
626
app/helper/interaction.py
Normal file
626
app/helper/interaction.py
Normal file
@@ -0,0 +1,626 @@
|
||||
import math
|
||||
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 app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.schemas import Notification
|
||||
from app.schemas.message import ChannelCapabilityManager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingSlashInteraction:
|
||||
"""
|
||||
通用 slash 命令交互上下文。
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
user_id: str
|
||||
channel: Optional[MessageChannel]
|
||||
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:
|
||||
"""
|
||||
管理单个 slash 命令的交互会话。
|
||||
"""
|
||||
|
||||
_ttl = timedelta(hours=24)
|
||||
|
||||
def __init__(self):
|
||||
self._by_id: Dict[str, PendingSlashInteraction] = {}
|
||||
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],
|
||||
command: str,
|
||||
channel: Optional[MessageChannel],
|
||||
source: Optional[str],
|
||||
username: Optional[str],
|
||||
) -> PendingSlashInteraction:
|
||||
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 = PendingSlashInteraction(
|
||||
request_id=uuid.uuid4().hex[:12],
|
||||
user_id=user_key,
|
||||
command=command,
|
||||
channel=channel,
|
||||
source=source,
|
||||
username=username,
|
||||
)
|
||||
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[PendingSlashInteraction]:
|
||||
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[PendingSlashInteraction]:
|
||||
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()
|
||||
|
||||
|
||||
def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool:
|
||||
"""
|
||||
渠道同时支持按钮和回调时,优先使用按钮交互。
|
||||
"""
|
||||
return bool(
|
||||
channel
|
||||
and ChannelCapabilityManager.supports_buttons(channel)
|
||||
and ChannelCapabilityManager.supports_callbacks(channel)
|
||||
)
|
||||
|
||||
|
||||
def supports_markdown(channel: Optional[MessageChannel]) -> 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: MessageChannel,
|
||||
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)
|
||||
):
|
||||
edited = chain.edit_message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
message_id=original_message_id,
|
||||
chat_id=original_chat_id,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
if edited:
|
||||
return
|
||||
|
||||
chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
@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)
|
||||
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(frozen=True)
|
||||
class AgentInteractionOption:
|
||||
"""
|
||||
Agent 交互选项。
|
||||
"""
|
||||
|
||||
label: str
|
||||
value: str
|
||||
|
||||
|
||||
@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):
|
||||
self._pending_interactions: Dict[str, PendingAgentInteraction] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def _cleanup_locked(self) -> None:
|
||||
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()
|
||||
@@ -1,244 +0,0 @@
|
||||
import math
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from app.schemas import Notification
|
||||
from app.schemas.message import ChannelCapabilityManager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingSlashInteraction:
|
||||
"""
|
||||
通用 slash 命令交互上下文。
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
user_id: str
|
||||
channel: Optional[MessageChannel]
|
||||
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:
|
||||
"""
|
||||
管理单个 slash 命令的交互会话。
|
||||
"""
|
||||
|
||||
_ttl = timedelta(hours=24)
|
||||
|
||||
def __init__(self):
|
||||
self._by_id: Dict[str, PendingSlashInteraction] = {}
|
||||
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],
|
||||
command: str,
|
||||
channel: Optional[MessageChannel],
|
||||
source: Optional[str],
|
||||
username: Optional[str],
|
||||
) -> PendingSlashInteraction:
|
||||
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 = PendingSlashInteraction(
|
||||
request_id=uuid.uuid4().hex[:12],
|
||||
user_id=user_key,
|
||||
command=command,
|
||||
channel=channel,
|
||||
source=source,
|
||||
username=username,
|
||||
)
|
||||
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[PendingSlashInteraction]:
|
||||
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[PendingSlashInteraction]:
|
||||
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()
|
||||
|
||||
|
||||
def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool:
|
||||
"""
|
||||
渠道同时支持按钮和回调时,优先使用按钮交互。
|
||||
"""
|
||||
return bool(
|
||||
channel
|
||||
and ChannelCapabilityManager.supports_buttons(channel)
|
||||
and ChannelCapabilityManager.supports_callbacks(channel)
|
||||
)
|
||||
|
||||
|
||||
def supports_markdown(channel: Optional[MessageChannel]) -> bool:
|
||||
"""
|
||||
仅在支持 Markdown 的渠道上输出 Markdown 内容。
|
||||
"""
|
||||
return bool(channel and ChannelCapabilityManager.supports_markdown(channel))
|
||||
|
||||
|
||||
def page_items(
|
||||
items: Sequence,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> Tuple[List, 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: PendingSlashInteraction,
|
||||
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: MessageChannel,
|
||||
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)
|
||||
):
|
||||
edited = chain.edit_message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
message_id=original_message_id,
|
||||
chat_id=original_chat_id,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
if edited:
|
||||
return
|
||||
|
||||
chain.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def escape_markdown_table_cell(value: object) -> str:
|
||||
"""
|
||||
最小化转义 Markdown 表格中的特殊字符。
|
||||
"""
|
||||
text = str(value or "").replace("\n", "<br>")
|
||||
text = text.replace("|", "\\|")
|
||||
return text
|
||||
|
||||
|
||||
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])
|
||||
Reference in New Issue
Block a user