feat: add plugin-scoped text input sessions (#6068)

This commit is contained in:
qqcomeup
2026-07-07 05:15:18 +08:00
committed by GitHub
parent 136c1baed3
commit 827ed8330c
8 changed files with 1380 additions and 6 deletions

View File

@@ -29,7 +29,12 @@ from app.db.models import TransferHistory
from app.db.transferhistory_oper import TransferHistoryOper
from app.db.user_oper import UserOper
from app.helper.directory import DirectoryHelper
from app.helper.interaction import agent_interaction_manager, media_interaction_manager, PendingMediaInteraction
from app.helper.interaction import (
agent_interaction_manager,
media_interaction_manager,
plugin_input_interaction_manager,
PendingMediaInteraction,
)
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import CommingMessage, DownloadDirectory, FileURI, NotExistMediaInfo, Notification
@@ -160,7 +165,7 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
@@ -201,6 +206,20 @@ class MessageChain(ChainBase):
)
return
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return
is_agent_message = self._is_agent_message(
userid=userid,
text=text,
@@ -259,7 +278,7 @@ class MessageChain(ChainBase):
source: str,
userid: Union[str, int],
username: str,
text: str,
text: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
images: Optional[List[CommingMessage.MessageImage]] = None,
@@ -290,6 +309,20 @@ class MessageChain(ChainBase):
)
return False
if self._handle_plugin_input_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
text=text,
original_chat_id=original_chat_id,
images=images,
audio_refs=audio_refs,
files=files,
has_audio_input=has_audio_input,
):
return False
no_ai_requested, no_ai_text = self._strip_no_ai_prefix(text)
if no_ai_requested:
text = no_ai_text
@@ -415,6 +448,112 @@ 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,
) -> 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
request, status = plugin_input_interaction_manager.consume_by_user(
userid, channel, source, original_chat_id
)
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,
"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 text.strip().lower() in {"取消", "退出", "q", "quit", "exit"}:
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,
"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,
"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]:
"""

View File

@@ -439,11 +439,19 @@ class EventManager(metaclass=Singleton):
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
target_plugin_id = None
if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
if target_plugin_id and not self.__should_dispatch_to_target_plugin(
handler, handler_id, str(target_plugin_id)
):
continue
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
if isinstance(event.event_data, dict):
event_data_copy = event.event_data.copy()
event_data_copy.pop("__mp_target_plugin_id", None)
else:
event_data_copy = event.event_data
isolated_event = Event(event_type=event.event_type,
@@ -459,6 +467,34 @@ class EventManager(metaclass=Singleton):
# 对于同步函数,在线程池中运行
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
@classmethod
def __should_dispatch_to_target_plugin(
cls,
handler: Callable,
handler_identifier: str,
target_plugin_id: str,
) -> bool:
"""
限定插件输入事件只投递给目标插件,避免自由文本被其他插件观察到。
"""
class_name, method_name = cls.__parse_handler_names(handler)
if class_name != target_plugin_id:
return False
identifier_parts = (handler_identifier or "").split(".")
if len(identifier_parts) < 2:
logger.debug(
"Target plugin dispatch skipped because handler identifier is invalid: "
f"target={target_plugin_id}, handler={handler_identifier}"
)
return False
if identifier_parts[-2:] != [class_name, method_name]:
logger.debug(
"Target plugin dispatch skipped because handler identifier does not match handler: "
f"target={target_plugin_id}, handler={handler_identifier}, parsed={class_name}.{method_name}"
)
return False
return True
def __safe_invoke_handler(self, handler: Callable, event: Event):
"""
调用处理器,处理链式或广播事件

View File

@@ -398,6 +398,286 @@ class MediaInteractionManager:
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)
@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]]:
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,
) -> 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)
}
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=str(chat_id) if chat_id not in (None, "") else None,
prompt_id=prompt_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]:
request, _ = self.consume_by_user(user_id, channel, source, chat_id)
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,
) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]:
with self._lock:
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)
request = self._by_id.pop(request_id, None)
if request:
status = "expired" if request.expires_at < datetime.now() else "active"
return request, status
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)
return request, "expired"
self._cleanup_locked()
return None, None
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]:
_, 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]]:
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:
"""

View File

@@ -521,6 +521,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
userid=userid,
link=message.link,
buttons=message.buttons,
force_reply=message.force_reply,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
disable_web_page_preview=message.disable_web_page_preview,

View File

@@ -15,6 +15,10 @@ from telebot.types import (
InlineKeyboardButton,
InputMediaPhoto,
)
try:
from telebot.types import ForceReply
except ImportError:
ForceReply = None
from telegramify_markdown import standardize, telegramify # noqa
try:
from telegramify_markdown import entities_to_markdownv2 # noqa
@@ -584,6 +588,7 @@ class Telegram:
userid: Optional[str] = None,
link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
force_reply: bool = False,
original_message_id: Optional[int] = None,
original_chat_id: Optional[str] = None,
disable_web_page_preview: Optional[bool] = None,
@@ -598,6 +603,7 @@ class Telegram:
:param userid: 用户ID如有则只发消息给该用户
:param link: 跳转链接
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
:param force_reply: 是否请求 Telegram 客户端强制回复
:param original_message_id: 原消息ID如果提供则编辑原消息
:param original_chat_id: 原消息的聊天ID编辑消息时需要
:param disable_web_page_preview: 是否禁用链接预览
@@ -634,9 +640,31 @@ class Telegram:
reply_markup = None
if buttons:
reply_markup = self._create_inline_keyboard(buttons)
elif force_reply and ForceReply:
reply_markup = self._create_force_reply_markup()
# 判断是编辑消息还是发送新消息
if original_message_id and original_chat_id:
if force_reply and reply_markup and not buttons:
sent = self.__send_request(
userid=original_chat_id,
image=image,
caption=caption,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
reply_to_message_id=original_message_id,
)
self._stop_typing_if_needed(chat_id, stop_typing)
if sent and hasattr(sent, "message_id"):
return {
"success": True,
"message_id": sent.message_id,
"chat_id": sent.chat.id if hasattr(sent, "chat") else chat_id,
}
elif sent:
return {"success": True}
return {"success": False}
# 编辑消息
result = self.__edit_message(
original_chat_id,
@@ -679,6 +707,18 @@ class Telegram:
self._stop_typing_if_needed(chat_id, stop_typing)
return {"success": False}
@staticmethod
def _create_force_reply_markup():
if not ForceReply:
return None
try:
return ForceReply(selective=True, input_field_placeholder="请输入内容")
except TypeError:
try:
return ForceReply(selective=True)
except TypeError:
return ForceReply()
def send_voice(
self,
voice_path: str,
@@ -1285,12 +1325,14 @@ class Telegram:
reply_markup: Optional[InlineKeyboardMarkup] = None,
disable_web_page_preview: Optional[bool] = None,
parse_mode: Optional[str] = None,
reply_to_message_id: Optional[int] = None,
):
"""
向Telegram发送报文返回发送的消息对象
:param reply_markup: 内联键盘
:param disable_web_page_preview: 是否禁用链接预览
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2可传 HTML
:param reply_to_message_id: 回复的原消息ID
:return: 发送成功返回消息对象失败返回None
"""
parse_mode = self._normalize_parse_mode(parse_mode)
@@ -1299,6 +1341,8 @@ class Telegram:
"parse_mode": parse_mode,
"reply_markup": reply_markup,
}
if reply_to_message_id:
kwargs["reply_to_message_id"] = reply_to_message_id
# 处理图片
image = self.__process_image(image)

View File

@@ -243,6 +243,8 @@ class Notification(BaseModel):
targets: Optional[dict] = None
# 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]]
buttons: Optional[List[List[dict]]] = None
# Telegram ForceReply 回复标记
force_reply: bool = False
# 原消息ID用于编辑消息
original_message_id: Optional[Union[str, int]] = None
# 原消息的聊天ID用于编辑消息