mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
1925 lines
75 KiB
Python
1925 lines
75 KiB
Python
import asyncio
|
||
import base64
|
||
import mimetypes
|
||
import re
|
||
import uuid
|
||
from concurrent.futures import CancelledError as FutureCancelledError
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Optional, Dict, Union, List, Tuple
|
||
from urllib.parse import unquote, urlparse
|
||
|
||
from app.application.agent import (
|
||
get_running_agent_manager,
|
||
is_audio_input_available,
|
||
supports_image_input,
|
||
transcribe_audio,
|
||
)
|
||
from app.chain import ChainBase
|
||
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 global_vars
|
||
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.session import MessageSessionService
|
||
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.runtime.log import logger
|
||
from app.schemas.message import IncomingMessage
|
||
from app.schemas.message import Message
|
||
from app.schemas.notification import ChannelCapabilityManager
|
||
from app.schemas.types import EventType, NotificationChannel
|
||
from app.adapters.network.http import RequestUtils
|
||
|
||
|
||
class MessageChain(ChainBase):
|
||
"""
|
||
外来消息处理链
|
||
"""
|
||
|
||
_ai_prefix = "/ai"
|
||
_no_ai_prefix = "/noai"
|
||
# 用户会话信息 {userid: (session_id, last_time)}
|
||
_user_sessions: Dict[Union[str, int], tuple] = {}
|
||
# 会话超时时间(分钟)
|
||
_session_timeout_minutes: int = 24 * 60
|
||
|
||
@staticmethod
|
||
def _schedule_agent_session_clear(session_id: str, userid: Union[str, int]) -> None:
|
||
"""
|
||
异步调度 Agent 会话清理,避免同步消息链阻塞在模型资源释放上。
|
||
"""
|
||
if not session_id:
|
||
return
|
||
manager = get_running_agent_manager()
|
||
if manager is None:
|
||
return
|
||
clear_task = None
|
||
try:
|
||
clear_task = manager.clear_session(
|
||
session_id=session_id, user_id=str(userid)
|
||
)
|
||
asyncio.run_coroutine_threadsafe(
|
||
clear_task,
|
||
global_vars.loop,
|
||
)
|
||
except Exception as e:
|
||
if clear_task:
|
||
clear_task.close()
|
||
logger.warning(f"调度清理智能体会话失败: {e}")
|
||
|
||
def _cleanup_expired_user_sessions(self, current_time: datetime) -> None:
|
||
"""
|
||
清理超过复用窗口的用户会话映射,并同步释放旧 Agent 实例。
|
||
"""
|
||
self._message_session_service().cleanup(current_time)
|
||
|
||
def _message_session_service(self) -> MessageSessionService:
|
||
"""用类级兼容映射构建可测试的用户会话服务。"""
|
||
return MessageSessionService(
|
||
sessions=self._user_sessions,
|
||
timeout_minutes=self._session_timeout_minutes,
|
||
expired_handler=self._schedule_agent_session_clear,
|
||
)
|
||
|
||
@dataclass
|
||
class _ProcessingStatus:
|
||
channel: NotificationChannel
|
||
source: str
|
||
userid: Optional[Union[str, int]] = None
|
||
message_id: Optional[Union[str, int]] = None
|
||
chat_id: Optional[Union[str, int]] = None
|
||
metadata: Optional[Dict[str, Any]] = None
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""转换为模块接口可安全传递的普通字典。"""
|
||
return {
|
||
"channel": self.channel.value,
|
||
"source": self.source,
|
||
"userid": self.userid,
|
||
"message_id": self.message_id,
|
||
"chat_id": self.chat_id,
|
||
"metadata": self.metadata or {},
|
||
}
|
||
|
||
def process(self, body: Any, form: Any, args: Any) -> None:
|
||
"""
|
||
调用模块识别消息内容
|
||
"""
|
||
# 消息来源
|
||
source = args.get("source")
|
||
# 获取消息内容
|
||
info = self.message_parser(source=source, body=body, form=form, args=args)
|
||
if not info:
|
||
logger.info("消息链路未识别到有效消息: source=%s", source)
|
||
return
|
||
# 更新消息来源
|
||
source = info.source
|
||
# 渠道
|
||
channel = info.channel
|
||
# 用户ID
|
||
userid = info.userid
|
||
# 用户名(当渠道未提供公开用户名时,回退为 userid 的字符串,避免后续类型校验异常)
|
||
username = (
|
||
str(info.username) if info.username not in (None, "") else str(userid)
|
||
)
|
||
if userid is None or userid == "":
|
||
logger.debug(f"未识别到用户ID:{body}{form}{args}")
|
||
return
|
||
|
||
# 消息内容
|
||
text = str(info.text).strip() if info.text else ""
|
||
images = info.images
|
||
audio_refs = info.audio_refs
|
||
files = info.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
|
||
|
||
original_message_id = info.message_id
|
||
original_chat_id = info.chat_id
|
||
reply_to_message_id = info.reply_to_message_id
|
||
|
||
# 处理消息
|
||
self.handle_message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
is_channel_admin=info.is_channel_admin,
|
||
text=text,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
reply_to_message_id=reply_to_message_id,
|
||
images=images,
|
||
audio_refs=audio_refs,
|
||
files=files,
|
||
callback_data=callback_data,
|
||
)
|
||
|
||
def handle_message(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: Optional[str],
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
images: Optional[List[IncomingMessage.MessageImage]] = None,
|
||
audio_refs: Optional[List[str]] = None,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]] = None,
|
||
reply_to_message_id: Optional[Union[str, int]] = None,
|
||
is_channel_admin: Optional[bool] = None,
|
||
callback_data: Optional[str] = None,
|
||
) -> None:
|
||
"""
|
||
识别消息内容,执行操作
|
||
"""
|
||
images = IncomingMessage.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:
|
||
# 语音输入只用于转写为文本,不默认改变回复形式。
|
||
has_audio_input = bool(audio_refs)
|
||
if audio_refs:
|
||
transcript = self._transcribe_audio_refs(audio_refs, channel, source)
|
||
merged_parts = []
|
||
seen_parts = set()
|
||
for item in [text.strip() if text else "", transcript or ""]:
|
||
normalized = item.strip()
|
||
if not normalized or normalized in seen_parts:
|
||
continue
|
||
seen_parts.add(normalized)
|
||
merged_parts.append(normalized)
|
||
text = "\n".join(merged_parts).strip()
|
||
if not text:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="语音识别失败,请稍后重试",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
|
||
if self._handle_secret_confirmation_control(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
is_channel_admin=is_channel_admin,
|
||
text=text,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
images=images,
|
||
audio_refs=audio_refs,
|
||
files=files,
|
||
has_audio_input=has_audio_input,
|
||
):
|
||
return
|
||
|
||
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,
|
||
reply_to_message_id=reply_to_message_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,
|
||
callback_data=normalized_callback,
|
||
images=images,
|
||
files=files,
|
||
has_audio_input=has_audio_input,
|
||
)
|
||
|
||
# 回调消息不写入普通用户消息历史
|
||
if normalized_callback is None and not is_agent_message:
|
||
self._record_user_message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
text=text,
|
||
)
|
||
|
||
if not is_agent_message:
|
||
processing_status = self._mark_message_processing_started(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
text=text,
|
||
)
|
||
|
||
processing_finish_deferred = self._handle_message_core(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
is_channel_admin=is_channel_admin,
|
||
text=text,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
reply_to_message_id=reply_to_message_id,
|
||
images=images,
|
||
audio_refs=audio_refs,
|
||
files=files,
|
||
has_audio_input=has_audio_input,
|
||
processing_status=processing_status,
|
||
callback_data=normalized_callback,
|
||
) is True
|
||
finally:
|
||
if not processing_finish_deferred:
|
||
self._mark_message_processing_finished(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
status=processing_status,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
)
|
||
|
||
def _handle_secret_confirmation_control(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: Optional[str],
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
images: Optional[List[IncomingMessage.MessageImage]] = None,
|
||
audio_refs: Optional[List[str]] = None,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]] = None,
|
||
has_audio_input: bool = False,
|
||
is_channel_admin: Optional[bool] = None,
|
||
) -> bool:
|
||
"""将 TG/飞书中的确认控制文本交回所属 Agent 会话。"""
|
||
if channel not in {NotificationChannel.Telegram, NotificationChannel.Feishu}:
|
||
return False
|
||
if str(text or "").strip() not in {"确认", "取消"}:
|
||
return False
|
||
if images or audio_refs or files or has_audio_input:
|
||
return False
|
||
|
||
session_info = self._user_sessions.get(userid)
|
||
if not session_info:
|
||
return False
|
||
session_id, _ = session_info
|
||
manager = get_running_agent_manager()
|
||
if manager is None or not manager.matches_secret_confirmation(
|
||
session_id,
|
||
str(userid),
|
||
channel=channel.value,
|
||
source=source,
|
||
):
|
||
return False
|
||
return self._handle_ai_message(
|
||
text=str(text).strip(),
|
||
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,
|
||
images=images,
|
||
files=files,
|
||
session_id=session_id,
|
||
has_audio_input=has_audio_input,
|
||
)
|
||
|
||
def _handle_message_core(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: Optional[str],
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
images: Optional[List[IncomingMessage.MessageImage]] = None,
|
||
audio_refs: Optional[List[str]] = None,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]] = None,
|
||
has_audio_input: bool = False,
|
||
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:
|
||
"""执行实际消息路由,便于统一包裹处理中状态。"""
|
||
|
||
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(
|
||
callback_data=callback_data,
|
||
context=context,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"渠道 %s 不支持回调,但收到了回调消息:%s",
|
||
channel.value,
|
||
callback_data,
|
||
)
|
||
return False
|
||
|
||
if PluginInputInteractionHandler(messenger=self).handle_text(
|
||
context=context,
|
||
text=text,
|
||
reply_to_message_id=reply_to_message_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
|
||
if not text:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="请输入要使用传统交互处理的内容",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
if text.startswith("/") and not self._has_ai_prefix(text):
|
||
self.eventmanager.send_event(
|
||
EventType.CommandExcute,
|
||
{
|
||
"cmd": text,
|
||
"user": userid,
|
||
"channel": channel,
|
||
"source": source,
|
||
"processing_status": processing_status.to_dict()
|
||
if processing_status
|
||
else None,
|
||
},
|
||
)
|
||
return bool(processing_status)
|
||
|
||
if not no_ai_requested and self._has_ai_prefix(text):
|
||
return self._handle_ai_message(
|
||
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,
|
||
images=images,
|
||
files=files,
|
||
has_audio_input=has_audio_input,
|
||
)
|
||
|
||
# 最近活动的传统交互会话(按创建时间选择,避免旧会话抢占新输入)
|
||
if self._interaction_router().dispatch_active_text(context, text):
|
||
return False
|
||
|
||
if (
|
||
not no_ai_requested
|
||
and
|
||
self.runtime_config.ai_agent_enable
|
||
and (
|
||
self.runtime_config.ai_agent_global
|
||
or images
|
||
or files
|
||
or has_audio_input
|
||
)
|
||
):
|
||
return self._handle_ai_message(
|
||
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,
|
||
images=images,
|
||
files=files,
|
||
has_audio_input=has_audio_input,
|
||
)
|
||
|
||
if _MediaInteractionChain().handle_text_interaction(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
text=text,
|
||
):
|
||
return False
|
||
|
||
self.eventmanager.send_event(
|
||
EventType.UserMessage,
|
||
{
|
||
"text": text,
|
||
"userid": userid,
|
||
"channel": channel,
|
||
"source": source,
|
||
"chat_id": original_chat_id,
|
||
"reply_to_message_id": reply_to_message_id,
|
||
},
|
||
)
|
||
return False
|
||
|
||
@classmethod
|
||
def _strip_no_ai_prefix(cls, text: str) -> Tuple[bool, str]:
|
||
"""
|
||
解析 /noai 前缀,显式要求本条消息绕过全局智能体。
|
||
"""
|
||
normalized = (text or "").strip()
|
||
pattern = rf"^{re.escape(cls._no_ai_prefix)}(?:\s+|[::]\s*|$)(.*)$"
|
||
match = re.match(pattern, normalized, re.IGNORECASE | re.DOTALL)
|
||
if not match:
|
||
return False, text
|
||
return True, match.group(1).strip()
|
||
|
||
@classmethod
|
||
def _has_ai_prefix(cls, text: str) -> bool:
|
||
"""
|
||
判断消息是否使用显式 AI 前缀。
|
||
"""
|
||
return (text or "").lower().startswith(cls._ai_prefix)
|
||
|
||
def _is_agent_message(
|
||
self,
|
||
userid: Union[str, int],
|
||
text: str,
|
||
callback_data: Optional[str] = None,
|
||
images: Optional[List[IncomingMessage.MessageImage]] = None,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]] = None,
|
||
has_audio_input: bool = False,
|
||
) -> bool:
|
||
"""
|
||
判断本条消息是否会进入 Agent worker,由 Agent worker 管理 typing 生命周期。
|
||
"""
|
||
if callback_data:
|
||
return parse_agent_choice_callback(callback_data) is not None
|
||
if self._has_ai_prefix(text):
|
||
return True
|
||
if text.startswith("/"):
|
||
return False
|
||
if not (
|
||
self.runtime_config.ai_agent_enable
|
||
and (
|
||
self.runtime_config.ai_agent_global
|
||
or images
|
||
or files
|
||
or has_audio_input
|
||
)
|
||
):
|
||
return False
|
||
if self._interaction_router().has_pending(userid):
|
||
return False
|
||
return True
|
||
|
||
def _mark_message_processing_started(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
original_message_id: Optional[Union[str, int]],
|
||
original_chat_id: Optional[Union[str, int]],
|
||
text: str,
|
||
) -> Optional[_ProcessingStatus]:
|
||
"""为支持的渠道标记“消息正在处理”。"""
|
||
status = self.start_message_processing_status(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
message_id=original_message_id,
|
||
chat_id=original_chat_id,
|
||
text=text,
|
||
)
|
||
if not status:
|
||
return None
|
||
|
||
metadata = status.get("metadata")
|
||
return self._ProcessingStatus(
|
||
channel=channel,
|
||
source=source,
|
||
userid=status.get("userid", userid),
|
||
message_id=status.get("message_id", original_message_id),
|
||
chat_id=status.get("chat_id", original_chat_id),
|
||
metadata=metadata if isinstance(metadata, dict) else {},
|
||
)
|
||
|
||
def _mark_message_processing_finished(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
status: Optional[_ProcessingStatus] = None,
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[Union[str, int]] = None,
|
||
) -> None:
|
||
"""
|
||
结束渠道侧“消息正在处理”状态。
|
||
不同渠道的表现可能是 reaction、typing 等,消息链只负责调用通用模块接口。
|
||
"""
|
||
if not status:
|
||
return
|
||
self.finish_message_processing_status(
|
||
status=status.to_dict(),
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
message_id=status.message_id or original_message_id,
|
||
chat_id=status.chat_id or original_chat_id,
|
||
)
|
||
|
||
def _interaction_router(self) -> InteractionRouter:
|
||
"""构造交互路由器,文本会话按创建时间选择,回调路由注册顺序即优先级。"""
|
||
|
||
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
|
||
|
||
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,
|
||
context=context,
|
||
)
|
||
# Agent 选择回调会接续会话,需要延迟结束处理中状态
|
||
return InteractionDispatch(handled=handled, defer_processing_finish=handled)
|
||
|
||
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,
|
||
"text": content,
|
||
"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 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(
|
||
Message(
|
||
channel=context.channel,
|
||
source=context.source,
|
||
userid=context.user_id,
|
||
username=context.username,
|
||
title="回调数据格式错误,请检查!",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
|
||
def _handle_agent_choice_callback(
|
||
self,
|
||
*,
|
||
callback_data: str,
|
||
context: InteractionContext,
|
||
) -> bool:
|
||
"""
|
||
将 Agent 按钮选择回传为同一会话中的下一条用户消息。
|
||
"""
|
||
callback = parse_agent_choice_callback(callback_data)
|
||
if not callback:
|
||
return False
|
||
|
||
request_id, option_index = callback
|
||
resolved = agent_interaction_manager.resolve(
|
||
request_id=request_id,
|
||
option_index=option_index,
|
||
user_id=str(context.user_id),
|
||
)
|
||
if not resolved:
|
||
self.post_message(
|
||
Message(
|
||
channel=context.channel,
|
||
source=context.source,
|
||
userid=context.user_id,
|
||
username=context.username,
|
||
title="该选择已失效,请重新发起选择",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
request, option = resolved
|
||
selected_text = option.value
|
||
self._update_interaction_message_feedback(
|
||
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(context.user_id, request.session_id)
|
||
return self._handle_ai_message(
|
||
text=selected_text,
|
||
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,
|
||
)
|
||
|
||
def _update_interaction_message_feedback(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
original_message_id: Optional[Union[str, int]],
|
||
original_chat_id: Optional[str],
|
||
prompt: str,
|
||
selected_label: str,
|
||
title: Optional[str] = None,
|
||
) -> None:
|
||
"""
|
||
在用户点击交互按钮后,立即更新原消息,明确显示已选择的内容。
|
||
"""
|
||
if not original_message_id or not original_chat_id:
|
||
return
|
||
|
||
lines = [prompt.strip()]
|
||
if selected_label:
|
||
lines.append(f"已选择:{selected_label}")
|
||
feedback_text = "\n\n".join(line for line in lines if line)
|
||
self.edit_message(
|
||
channel=channel,
|
||
source=source,
|
||
message_id=original_message_id,
|
||
chat_id=original_chat_id,
|
||
title=title,
|
||
text=feedback_text,
|
||
)
|
||
|
||
def _get_or_create_session_id(self, userid: Union[str, int]) -> str:
|
||
"""
|
||
获取或创建会话ID
|
||
如果用户上次会话在15分钟内,则复用相同的会话ID;否则创建新的会话ID
|
||
"""
|
||
resolution = self._message_session_service().resolve(userid)
|
||
if resolution.reused:
|
||
logger.info(
|
||
f"复用会话ID: {resolution.session_id}, 用户: {userid}, "
|
||
f"距离上次会话: {resolution.inactive_minutes:.1f}分钟"
|
||
)
|
||
else:
|
||
logger.info(f"创建新会话ID: {resolution.session_id}, 用户: {userid}")
|
||
return resolution.session_id
|
||
|
||
def _bind_session_id(self, userid: Union[str, int], session_id: str) -> None:
|
||
"""
|
||
将用户会话绑定到指定的 session_id,并刷新最后活动时间。
|
||
"""
|
||
self._message_session_service().bind(userid, session_id)
|
||
|
||
def bind_user_session(self, userid: Union[str, int], session_id: str) -> None:
|
||
"""
|
||
绑定用户与指定智能体会话,供非传统入口复用远程命令状态查询。
|
||
|
||
:param userid: 用户 ID
|
||
:param session_id: 智能体会话 ID
|
||
"""
|
||
self._bind_session_id(userid, session_id)
|
||
|
||
def _record_user_message(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
text: str,
|
||
) -> None:
|
||
"""
|
||
保存一条用户消息到消息历史与数据库。
|
||
"""
|
||
self.messagehelper.put(
|
||
IncomingMessage(
|
||
userid=userid,
|
||
username=username,
|
||
channel=channel,
|
||
source=source,
|
||
text=text,
|
||
),
|
||
role="user",
|
||
)
|
||
self.messageoper.add(
|
||
channel=channel,
|
||
source=source,
|
||
userid=username or userid,
|
||
text=text,
|
||
action=0,
|
||
)
|
||
|
||
def clear_user_session(self, userid: Union[str, int]) -> bool:
|
||
"""
|
||
清除指定用户的会话信息
|
||
返回是否成功清除
|
||
"""
|
||
session_id = self._message_session_service().clear(userid)
|
||
if session_id:
|
||
logger.info(f"已清除用户 {userid} 的会话: {session_id}")
|
||
return True
|
||
return False
|
||
|
||
def remote_clear_session(
|
||
self,
|
||
channel: NotificationChannel,
|
||
userid: Union[str, int],
|
||
source: Optional[str] = None,
|
||
):
|
||
"""
|
||
清除用户会话(远程命令接口)
|
||
"""
|
||
# 获取并清除会话信息
|
||
session_id = self._message_session_service().clear(userid)
|
||
if session_id:
|
||
logger.info(f"已清除用户 {userid} 的会话: {session_id}")
|
||
|
||
# 如果有会话ID,同时清除智能体的会话记忆
|
||
if session_id:
|
||
manager = get_running_agent_manager()
|
||
clear_task = None
|
||
if manager is not None:
|
||
try:
|
||
clear_task = manager.clear_session(
|
||
session_id=session_id, user_id=str(userid)
|
||
)
|
||
asyncio.run_coroutine_threadsafe(
|
||
clear_task,
|
||
global_vars.loop,
|
||
)
|
||
except Exception as e:
|
||
if clear_task:
|
||
clear_task.close()
|
||
logger.warning(f"清除智能体会话记忆失败: {e}")
|
||
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="智能体会话已清除,下次将创建新的会话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
else:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="您当前没有活跃的智能体会话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
def remote_stop_agent(
|
||
self,
|
||
channel: NotificationChannel,
|
||
userid: Union[str, int],
|
||
source: Optional[str] = None,
|
||
):
|
||
"""
|
||
应急停止当前正在执行的Agent推理(远程命令接口)。
|
||
与 /clear_session 不同,此命令不会清除会话和记忆,
|
||
停止后用户仍可继续对话。
|
||
"""
|
||
# 查找用户的会话ID(不弹出,保留会话)
|
||
session_info = self._message_session_service().get(userid)
|
||
if session_info:
|
||
session_id, _ = session_info
|
||
manager = get_running_agent_manager()
|
||
try:
|
||
if manager is None:
|
||
stopped = False
|
||
else:
|
||
future = asyncio.run_coroutine_threadsafe(
|
||
manager.stop_current_task(session_id=session_id),
|
||
global_vars.loop,
|
||
)
|
||
stopped = future.result(timeout=10)
|
||
except Exception as e:
|
||
logger.warning(f"停止Agent推理失败: {e}")
|
||
stopped = False
|
||
|
||
if stopped:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="智能体推理已应急停止,会话记忆已保留,您可以继续对话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
else:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="当前没有正在执行的智能体任务",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
else:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="您当前没有活跃的智能体会话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _format_token_count(value: Optional[int]) -> str:
|
||
return f"{value:,}" if value is not None else "未知"
|
||
|
||
@classmethod
|
||
def _format_session_status_text(cls, status: Dict[str, Any]) -> str:
|
||
context_window_tokens = status.get("context_window_tokens")
|
||
last_input_tokens = status.get("last_input_tokens")
|
||
if context_window_tokens and status.get("model_call_count"):
|
||
context_ratio = status.get("last_context_usage_ratio")
|
||
if (
|
||
context_ratio is None
|
||
and status.get("last_input_usage_available") is True
|
||
and last_input_tokens is not None
|
||
):
|
||
context_ratio = last_input_tokens / context_window_tokens
|
||
context_usage_text = (
|
||
f"{cls._format_token_count(last_input_tokens)} / "
|
||
f"{cls._format_token_count(context_window_tokens)} "
|
||
f"({context_ratio * 100:.2f}%)"
|
||
if context_ratio is not None
|
||
else f"{cls._format_token_count(last_input_tokens)} / "
|
||
f"{cls._format_token_count(context_window_tokens)}"
|
||
)
|
||
else:
|
||
context_usage_text = "暂无模型调用数据"
|
||
|
||
lines = [
|
||
f"会话ID: {status.get('session_id') or '未知'}",
|
||
f"执行状态: {'运行中' if status.get('is_processing') else '空闲'}",
|
||
f"当前模型: {status.get('model') or '未知'}",
|
||
f"上下文窗口: {cls._format_token_count(context_window_tokens)} tokens",
|
||
f"最近一次上下文占用: {context_usage_text}",
|
||
]
|
||
if status.get("last_request_estimate_available"):
|
||
estimated_tokens = status.get("last_estimated_input_tokens")
|
||
estimated_ratio = status.get("last_estimated_input_ratio")
|
||
estimate_text = (
|
||
f"{cls._format_token_count(estimated_tokens)} / "
|
||
f"{cls._format_token_count(context_window_tokens)}"
|
||
)
|
||
if estimated_ratio is not None:
|
||
estimate_text += f" ({estimated_ratio * 100:.2f}%)"
|
||
if status.get("last_estimated_over_input_limit"):
|
||
estimate_text += ",估算已超输入上限"
|
||
lines.extend(
|
||
[
|
||
f"最终请求估算: {estimate_text}",
|
||
"估算组成: "
|
||
f"消息 {cls._format_token_count(status.get('last_estimated_message_tokens'))} / "
|
||
f"系统 {cls._format_token_count(status.get('last_estimated_system_tokens'))} / "
|
||
f"工具 {cls._format_token_count(status.get('last_estimated_tool_tokens'))} / "
|
||
f"其中图片固定成本 {cls._format_token_count(status.get('last_estimated_multimodal_tokens'))}",
|
||
]
|
||
)
|
||
actual_input_tokens = status.get("last_actual_input_tokens")
|
||
estimate_error_tokens = status.get("last_estimate_error_tokens")
|
||
if actual_input_tokens is not None and estimate_error_tokens is not None:
|
||
estimate_error_ratio = status.get("last_estimate_error_ratio")
|
||
error_text = (
|
||
f"实际 {cls._format_token_count(actual_input_tokens)} / "
|
||
f"误差 {estimate_error_tokens:+,}"
|
||
)
|
||
if estimate_error_ratio is not None:
|
||
error_text += f" ({estimate_error_ratio:+.2%})"
|
||
lines.append(f"估算校准: {error_text}")
|
||
lines.append(
|
||
f"最近一次 tokens: 输入 {cls._format_token_count(status.get('last_input_tokens'))} / 输出 {cls._format_token_count(status.get('last_output_tokens'))} / 总计 {cls._format_token_count(status.get('last_total_tokens'))}"
|
||
)
|
||
if status.get("last_cache_usage_available"):
|
||
last_cache_ratio = status.get("last_cache_hit_ratio")
|
||
lines.append(
|
||
"最近一次缓存: "
|
||
f"命中 {cls._format_token_count(status.get('last_cache_read_input_tokens'))} / "
|
||
f"写入 {cls._format_token_count(status.get('last_cache_write_input_tokens'))} / "
|
||
f"未命中 {cls._format_token_count(status.get('last_uncached_input_tokens'))}"
|
||
+ (
|
||
f" ({last_cache_ratio * 100:.2f}%)"
|
||
if last_cache_ratio is not None
|
||
else ""
|
||
),
|
||
)
|
||
if status.get("cache_usage_available"):
|
||
total_cache_ratio = status.get("total_cache_hit_ratio")
|
||
lines.append(
|
||
"当前会话累计缓存: "
|
||
f"命中 {cls._format_token_count(status.get('total_cache_read_input_tokens'))} / "
|
||
f"写入 {cls._format_token_count(status.get('total_cache_write_input_tokens'))} / "
|
||
f"未命中 {cls._format_token_count(status.get('total_uncached_input_tokens'))}"
|
||
+ (
|
||
f" ({total_cache_ratio * 100:.2f}%)"
|
||
if total_cache_ratio is not None
|
||
else ""
|
||
),
|
||
)
|
||
pending_messages = status.get("pending_messages", 0)
|
||
queue_capacity = status.get("queue_capacity")
|
||
pending_text = (
|
||
f"{pending_messages} / {queue_capacity}"
|
||
if queue_capacity
|
||
else str(pending_messages)
|
||
)
|
||
lines.extend(
|
||
[
|
||
f"当前会话累计 tokens: 输入 {cls._format_token_count(status.get('total_input_tokens'))} / 输出 {cls._format_token_count(status.get('total_output_tokens'))} / 总计 {cls._format_token_count(status.get('total_tokens'))}",
|
||
f"模型调用次数: {status.get('model_call_count', 0)}",
|
||
f"排队消息数: {pending_text}",
|
||
f"最后更新: {status.get('last_updated_at') or '暂无'}",
|
||
]
|
||
)
|
||
if status.get("queue_rejections"):
|
||
lines.append(f"排队拒绝次数: {status['queue_rejections']}")
|
||
if status.get("shutdown_pending"):
|
||
lines.append("会话状态: 正在停止")
|
||
return "\n".join(lines)
|
||
|
||
def remote_session_status(
|
||
self,
|
||
channel: NotificationChannel,
|
||
userid: Union[str, int],
|
||
source: Optional[str] = None,
|
||
):
|
||
"""查询当前用户的智能体会话状态。"""
|
||
session_info = self._message_session_service().get(userid)
|
||
if not session_info:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="您当前没有活跃的智能体会话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
|
||
session_id, _ = session_info
|
||
manager = get_running_agent_manager()
|
||
if manager is None:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="您当前没有活跃的智能体会话",
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
status = manager.get_session_status(session_id=session_id)
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
title="当前智能体会话状态",
|
||
text=self._format_session_status_text(status),
|
||
userid=userid,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
def _handle_ai_message(
|
||
self,
|
||
text: str,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
original_message_id: Optional[Union[str, int]] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
images: Optional[List[IncomingMessage.MessageImage]] = None,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]] = None,
|
||
session_id: Optional[str] = None,
|
||
has_audio_input: bool = False,
|
||
is_channel_admin: Optional[bool] = None,
|
||
) -> bool:
|
||
"""
|
||
处理AI智能体消息
|
||
"""
|
||
try:
|
||
# 检查AI智能体是否启用
|
||
if not self.runtime_config.ai_agent_enable:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="MoviePilot智能助手未启用,请在系统设置中启用",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
manager = get_running_agent_manager()
|
||
if manager is None:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="MoviePilot智能助手服务尚未就绪,请稍后重试",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
images = IncomingMessage.MessageImage.normalize_list(images)
|
||
|
||
# 提取用户消息
|
||
if self._has_ai_prefix(text):
|
||
# 前缀匹配不区分大小写,但保留原始正文避免改变用户输入内容。
|
||
user_message = text[len(self._ai_prefix):].strip()
|
||
else:
|
||
user_message = text.strip() # 按原消息处理
|
||
|
||
if not user_message and not images and not files:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="请输入您的问题或需求",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
# 生成或复用会话ID
|
||
session_id = session_id or self._get_or_create_session_id(userid)
|
||
self._bind_session_id(userid, session_id)
|
||
|
||
# 将可直接输入给 LLM 的附件统一转换为 data URL
|
||
original_images = images
|
||
all_files = list(files or [])
|
||
if images and supports_image_input(
|
||
provider=self.runtime_config.llm_provider,
|
||
model=self.runtime_config.llm_model,
|
||
):
|
||
images = self._download_attachments_to_data_urls(
|
||
images, channel, source
|
||
)
|
||
if original_images and not images and not user_message and not files:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="附件读取失败,请稍后重试",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
elif images:
|
||
image_attachments = self._build_image_attachments(images)
|
||
if (
|
||
original_images
|
||
and not image_attachments
|
||
and not user_message
|
||
and not files
|
||
):
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="附件读取失败,请稍后重试",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
all_files.extend(image_attachments)
|
||
images = None
|
||
|
||
prepared_files = self._prepare_agent_files(
|
||
session_id=session_id,
|
||
files=all_files,
|
||
channel=channel,
|
||
source=source,
|
||
)
|
||
if all_files and not prepared_files and not user_message and not images:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="文件读取失败,请稍后重试",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return False
|
||
|
||
process_kwargs = {
|
||
"session_id": session_id,
|
||
"user_id": str(userid),
|
||
"message": user_message,
|
||
"images": images,
|
||
"files": prepared_files,
|
||
"channel": channel.value if channel else None,
|
||
"source": source,
|
||
"username": username,
|
||
"is_channel_admin": is_channel_admin,
|
||
"original_message_id": str(original_message_id)
|
||
if original_message_id
|
||
else None,
|
||
"original_chat_id": original_chat_id,
|
||
}
|
||
if has_audio_input:
|
||
process_kwargs["has_audio_input"] = True
|
||
# 在事件循环中处理,并消费跨线程 Future 的失败,避免队列满时静默丢消息。
|
||
submission_future = asyncio.run_coroutine_threadsafe(
|
||
manager.process_message(**process_kwargs),
|
||
global_vars.loop,
|
||
)
|
||
|
||
def _report_agent_submission_failure(completed) -> None:
|
||
try:
|
||
completed.result()
|
||
except BaseException as error:
|
||
if isinstance(
|
||
error,
|
||
(asyncio.CancelledError, FutureCancelledError),
|
||
):
|
||
return
|
||
error_code = getattr(error, "code", None)
|
||
if error_code == "agent_manager_queue_full":
|
||
title = "智能助手当前排队已满,请稍后重试"
|
||
elif error_code == "agent_manager_unavailable":
|
||
title = "智能助手服务暂不可用,请稍后重试"
|
||
else:
|
||
title = "智能助手处理失败,请查看日志"
|
||
logger.warning(f"Agent 消息提交失败: {error}")
|
||
try:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=title,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
save_history=False,
|
||
)
|
||
)
|
||
except Exception as report_error:
|
||
logger.error(f"发送 Agent 提交失败提示失败: {report_error}")
|
||
|
||
if submission_future is not None:
|
||
submission_future.add_done_callback(_report_agent_submission_failure)
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理AI智能体消息失败: {e}")
|
||
self.messagehelper.put(
|
||
f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手"
|
||
)
|
||
return False
|
||
|
||
def _transcribe_audio_refs(
|
||
self, audio_refs: List[str], channel: NotificationChannel, source: str
|
||
) -> Optional[str]:
|
||
"""
|
||
下载并识别语音消息,仅处理当前已接入的渠道。
|
||
"""
|
||
if not audio_refs:
|
||
return None
|
||
if not is_audio_input_available():
|
||
logger.warning("音频输入能力未配置或未启用,跳过语音识别")
|
||
return None
|
||
|
||
transcripts = []
|
||
for audio_ref in audio_refs:
|
||
try:
|
||
if audio_ref.startswith("tg://voice_file_id/"):
|
||
file_id = audio_ref.replace("tg://voice_file_id/", "", 1)
|
||
content = self.run_module(
|
||
"download_telegram_file_bytes", file_id=file_id, source=source
|
||
)
|
||
filename = "input.ogg"
|
||
elif audio_ref.startswith("tg://audio_file_id/"):
|
||
file_id = audio_ref.replace("tg://audio_file_id/", "", 1)
|
||
content = self.run_module(
|
||
"download_telegram_file_bytes", file_id=file_id, source=source
|
||
)
|
||
filename = "input.mp3"
|
||
elif audio_ref.startswith("wxwork://voice_media_id/"):
|
||
content = self.run_module(
|
||
"download_wechat_media_bytes",
|
||
media_ref=audio_ref,
|
||
source=source,
|
||
)
|
||
filename = "input.amr"
|
||
elif audio_ref.startswith("wxclaw://voice/"):
|
||
content = self.run_module(
|
||
"download_wechat_media_bytes",
|
||
media_ref=audio_ref,
|
||
source=source,
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.amr"
|
||
)
|
||
elif audio_ref.startswith("slack://file/"):
|
||
content = self.run_module(
|
||
"download_slack_file_bytes", file_ref=audio_ref, source=source
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
elif audio_ref.startswith("discord://file/"):
|
||
content = self.run_module(
|
||
"download_discord_file_bytes", file_ref=audio_ref, source=source
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
elif audio_ref.startswith("qq://file/"):
|
||
content = self.run_module(
|
||
"download_qq_file_bytes", file_ref=audio_ref, source=source
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
elif audio_ref.startswith("vocechat://file/"):
|
||
content = self.run_module(
|
||
"download_vocechat_file_bytes",
|
||
file_ref=audio_ref,
|
||
source=source,
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
elif audio_ref.startswith("synology://file/"):
|
||
content = self.run_module(
|
||
"download_synologychat_file_bytes",
|
||
file_ref=audio_ref,
|
||
source=source,
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
elif audio_ref.startswith("wxbot://voice"):
|
||
continue
|
||
elif audio_ref.startswith("feishu://file/"):
|
||
content = self.run_module(
|
||
"download_feishu_file_bytes", file_ref=audio_ref, source=source
|
||
)
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.opus"
|
||
)
|
||
elif audio_ref.startswith("http"):
|
||
resp = RequestUtils(timeout=30).get_res(audio_ref)
|
||
content = resp.content if resp and resp.content else None
|
||
filename = self._guess_audio_filename(
|
||
audio_ref, default="input.ogg"
|
||
)
|
||
else:
|
||
logger.debug(
|
||
"暂不支持的语音引用: channel=%s, source=%s, ref=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
audio_ref,
|
||
)
|
||
continue
|
||
|
||
if not content:
|
||
logger.warning(
|
||
"语音下载失败,跳过识别: channel=%s, source=%s, ref=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
audio_ref,
|
||
)
|
||
continue
|
||
|
||
transcript = transcribe_audio(
|
||
content=content, filename=filename
|
||
)
|
||
if transcript:
|
||
transcripts.append(transcript)
|
||
logger.info(
|
||
"语音识别成功: channel=%s, source=%s, ref=%s, text_len=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
audio_ref,
|
||
len(transcript),
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"语音识别失败: {err}")
|
||
|
||
return "\n".join(transcripts).strip() if transcripts else None
|
||
|
||
@staticmethod
|
||
def _guess_audio_filename(audio_ref: str, default: str = "input.ogg") -> str:
|
||
"""
|
||
根据引用中的扩展名推测音频文件名,便于 STT 服务识别格式。
|
||
"""
|
||
if not audio_ref:
|
||
return default
|
||
raw_ref = unquote(audio_ref).split("?", 1)[0].split("#", 1)[0]
|
||
match = re.search(
|
||
r"([^/]+\.(mp3|m4a|wav|ogg|oga|opus|aac|amr|flac|mpga|mpeg|webm))$",
|
||
raw_ref,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
if match:
|
||
return match.group(1)
|
||
return default
|
||
|
||
def _download_attachments_to_data_urls(
|
||
self,
|
||
attachments: List[IncomingMessage.MessageImage],
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
) -> Optional[List[str]]:
|
||
"""
|
||
下载可直接提供给 LLM 的附件内容,并统一转换为 data URL。
|
||
"""
|
||
normalized_attachments = IncomingMessage.MessageImage.normalize_list(attachments) or []
|
||
if not normalized_attachments:
|
||
return None
|
||
data_urls = []
|
||
for attachment in normalized_attachments:
|
||
attachment_ref = attachment.ref
|
||
try:
|
||
before_count = len(data_urls)
|
||
if attachment_ref.startswith("data:"):
|
||
data_urls.append(attachment_ref)
|
||
elif attachment_ref.startswith("tg://file_id/"):
|
||
file_id = attachment_ref.replace("tg://file_id/", "")
|
||
base64_data = self.run_module(
|
||
"download_telegram_file_to_base64",
|
||
file_id=file_id,
|
||
source=source,
|
||
)
|
||
if base64_data:
|
||
data_urls.append(f"data:image/jpeg;base64,{base64_data}")
|
||
elif attachment_ref.startswith(
|
||
"wxwork://media_id/"
|
||
) or attachment_ref.startswith(
|
||
"wxbot://image/"
|
||
) or attachment_ref.startswith(
|
||
"wxclaw://image/"
|
||
):
|
||
data_url = self.run_module(
|
||
"download_wechat_image_to_data_url",
|
||
image_ref=attachment_ref,
|
||
source=source,
|
||
)
|
||
if data_url:
|
||
data_urls.append(data_url)
|
||
elif attachment_ref.startswith("feishu://image/"):
|
||
data_url = self.run_module(
|
||
"download_feishu_image_to_data_url",
|
||
image_ref=attachment_ref,
|
||
source=source,
|
||
)
|
||
if data_url:
|
||
data_urls.append(data_url)
|
||
elif channel == NotificationChannel.Slack:
|
||
data_url = self.run_module(
|
||
"download_slack_file_to_data_url",
|
||
file_url=attachment_ref,
|
||
source=source,
|
||
)
|
||
if data_url:
|
||
data_urls.append(data_url)
|
||
elif attachment_ref.startswith("vocechat://file/"):
|
||
data_url = self.run_module(
|
||
"download_vocechat_image_to_data_url",
|
||
image_ref=attachment_ref,
|
||
source=source,
|
||
)
|
||
if data_url:
|
||
data_urls.append(data_url)
|
||
elif attachment_ref.startswith("http"):
|
||
resp = RequestUtils(timeout=30).get_res(attachment_ref)
|
||
if resp and resp.content:
|
||
base64_data = base64.b64encode(resp.content).decode()
|
||
mime_type = resp.headers.get("Content-Type", "image/jpeg")
|
||
data_urls.append(f"data:{mime_type};base64,{base64_data}")
|
||
else:
|
||
logger.debug(
|
||
"暂不支持直接转换为 data URL 的附件引用: channel=%s, source=%s, ref=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
attachment_ref,
|
||
)
|
||
continue
|
||
|
||
if len(data_urls) > before_count:
|
||
logger.info(
|
||
"附件读取成功并已转换为 data URL: channel=%s, source=%s, ref=%s, mime_type=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
attachment_ref,
|
||
attachment.mime_type,
|
||
)
|
||
except Exception as err:
|
||
logger.error(
|
||
"附件读取失败,无法转换为 data URL: channel=%s, source=%s, ref=%s, error=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
attachment_ref,
|
||
err,
|
||
)
|
||
return data_urls if data_urls else None
|
||
|
||
def _build_image_attachments(
|
||
self, images: List[IncomingMessage.MessageImage]
|
||
) -> List[IncomingMessage.MessageAttachment]:
|
||
"""
|
||
将图片引用转换为附件描述,以便按文件方式交给 Agent 处理。
|
||
"""
|
||
images = IncomingMessage.MessageImage.normalize_list(images)
|
||
if not images:
|
||
return []
|
||
|
||
attachments = []
|
||
for index, image in enumerate(images, start=1):
|
||
image_ref = image.ref
|
||
if not image_ref:
|
||
continue
|
||
name = image.name or self._guess_image_attachment_name(image_ref, index)
|
||
mime_type = image.mime_type or self._guess_image_mime_type(image_ref, name)
|
||
attachments.append(
|
||
IncomingMessage.MessageAttachment(
|
||
ref=image_ref,
|
||
name=name,
|
||
mime_type=mime_type,
|
||
size=image.size,
|
||
)
|
||
)
|
||
return attachments
|
||
|
||
def _prepare_agent_files(
|
||
self,
|
||
session_id: str,
|
||
files: Optional[List[IncomingMessage.MessageAttachment]],
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
) -> Optional[List[dict]]:
|
||
"""
|
||
下载用户上传的附件,落盘到临时目录,并生成 Agent 可消费的文件描述。
|
||
"""
|
||
if not files:
|
||
return None
|
||
|
||
prepared_files = []
|
||
for attachment in files:
|
||
payload = {
|
||
"name": attachment.name,
|
||
"mime_type": attachment.mime_type,
|
||
"size": attachment.size,
|
||
"ref": attachment.ref,
|
||
"status": "download_failed",
|
||
}
|
||
try:
|
||
content = self._download_message_file_bytes(
|
||
file_ref=attachment.ref,
|
||
channel=channel,
|
||
source=source,
|
||
)
|
||
if not content:
|
||
prepared_files.append(payload)
|
||
continue
|
||
|
||
local_path = self._save_agent_attachment(
|
||
session_id=session_id,
|
||
filename=attachment.name,
|
||
content=content,
|
||
mime_type=attachment.mime_type,
|
||
)
|
||
payload.update(
|
||
{
|
||
"local_path": str(local_path),
|
||
"status": "ready",
|
||
}
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"准备附件上下文失败: {attachment.ref}, error: {err}")
|
||
payload["error"] = str(err)
|
||
prepared_files.append(payload)
|
||
|
||
return prepared_files or None
|
||
|
||
def _download_message_file_bytes(
|
||
self, file_ref: str, channel: NotificationChannel, source: str
|
||
) -> Optional[bytes]:
|
||
"""
|
||
下载消息附件的原始字节内容。
|
||
"""
|
||
if not file_ref:
|
||
return None
|
||
if file_ref.startswith("data:"):
|
||
return self._decode_data_url_bytes(file_ref)
|
||
if file_ref.startswith("tg://file_id/"):
|
||
file_id = file_ref.replace("tg://file_id/", "", 1)
|
||
return self.run_module(
|
||
"download_telegram_file_bytes", file_id=file_id, source=source
|
||
)
|
||
if file_ref.startswith("tg://document_file_id/"):
|
||
file_id = file_ref.replace("tg://document_file_id/", "", 1)
|
||
return self.run_module(
|
||
"download_telegram_file_bytes", file_id=file_id, source=source
|
||
)
|
||
if file_ref.startswith("wxwork://media_id/"):
|
||
return self.run_module(
|
||
"download_wechat_media_bytes", media_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("wxwork://file_media_id/"):
|
||
return self.run_module(
|
||
"download_wechat_media_bytes", media_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("wxbot://image/"):
|
||
data_url = self.run_module(
|
||
"download_wechat_image_to_data_url", image_ref=file_ref, source=source
|
||
)
|
||
return self._decode_data_url_bytes(data_url) if data_url else None
|
||
if file_ref.startswith("wxclaw://image/"):
|
||
data_url = self.run_module(
|
||
"download_wechat_image_to_data_url", image_ref=file_ref, source=source
|
||
)
|
||
return self._decode_data_url_bytes(data_url) if data_url else None
|
||
if file_ref.startswith("wxbot://file/"):
|
||
file_url = unquote(file_ref.replace("wxbot://file/", "", 1))
|
||
resp = RequestUtils(timeout=30).get_res(file_url)
|
||
return resp.content if resp and resp.content else None
|
||
if file_ref.startswith("wxclaw://file/") or file_ref.startswith("wxclaw://voice/"):
|
||
return self.run_module(
|
||
"download_wechat_media_bytes", media_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("feishu://file/"):
|
||
return self.run_module(
|
||
"download_feishu_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("slack://file/"):
|
||
return self.run_module(
|
||
"download_slack_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("discord://file/"):
|
||
return self.run_module(
|
||
"download_discord_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("qq://file/"):
|
||
return self.run_module(
|
||
"download_qq_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("vocechat://file/"):
|
||
return self.run_module(
|
||
"download_vocechat_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("synology://file/"):
|
||
return self.run_module(
|
||
"download_synologychat_file_bytes", file_ref=file_ref, source=source
|
||
)
|
||
if file_ref.startswith("http"):
|
||
if channel == NotificationChannel.Slack:
|
||
data_url = self.run_module(
|
||
"download_slack_file_to_data_url", file_url=file_ref, source=source
|
||
)
|
||
return self._decode_data_url_bytes(data_url) if data_url else None
|
||
resp = RequestUtils(timeout=30).get_res(file_ref)
|
||
return resp.content if resp and resp.content else None
|
||
logger.debug(
|
||
"暂不支持的附件引用: channel=%s, source=%s, ref=%s",
|
||
channel.value if channel else None,
|
||
source,
|
||
file_ref,
|
||
)
|
||
return None
|
||
|
||
def _save_agent_attachment(
|
||
self,
|
||
session_id: str,
|
||
filename: Optional[str],
|
||
content: bytes,
|
||
mime_type: Optional[str] = None,
|
||
) -> Path:
|
||
"""
|
||
将用户上传文件写入临时目录,并返回本地路径。
|
||
"""
|
||
safe_name = self._sanitize_attachment_name(filename, mime_type)
|
||
base_dir = self.runtime_config.temporary_path / "agent_uploads" / session_id
|
||
base_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
file_id = uuid.uuid4().hex[:8]
|
||
local_path = base_dir / f"{file_id}_{safe_name}"
|
||
local_path.write_bytes(content or b"")
|
||
return local_path
|
||
|
||
@staticmethod
|
||
def _sanitize_attachment_name(
|
||
filename: Optional[str], mime_type: Optional[str] = None
|
||
) -> str:
|
||
"""
|
||
规范化附件文件名,避免路径穿越和非法字符。
|
||
"""
|
||
name = Path(filename or "attachment").name
|
||
name = re.sub(r"[^\w.\-]+", "_", name, flags=re.ASCII).strip("._")
|
||
if not name:
|
||
name = "attachment"
|
||
if "." not in name:
|
||
mime = (mime_type or "").split(";", 1)[0].strip().lower()
|
||
default_ext = {
|
||
"image/jpeg": ".jpg",
|
||
"image/png": ".png",
|
||
"image/gif": ".gif",
|
||
"image/webp": ".webp",
|
||
"image/bmp": ".bmp",
|
||
"application/json": ".json",
|
||
"text/plain": ".txt",
|
||
"text/markdown": ".md",
|
||
"text/csv": ".csv",
|
||
}.get(mime)
|
||
if default_ext:
|
||
name = f"{name}{default_ext}"
|
||
return name
|
||
|
||
@staticmethod
|
||
def _guess_image_attachment_name(image_ref: str, index: int) -> str:
|
||
"""
|
||
根据图片引用推测附件名。
|
||
"""
|
||
if not image_ref:
|
||
return f"image_{index}.jpg"
|
||
if image_ref.startswith("data:"):
|
||
mime_part = image_ref[5:].split(";", 1)[0].strip().lower()
|
||
ext = mimetypes.guess_extension(mime_part) or ".jpg"
|
||
return f"image_{index}{ext}"
|
||
|
||
parsed = urlparse(unquote(image_ref))
|
||
name = Path(parsed.path).name if parsed.path else ""
|
||
if name and "." in name:
|
||
return name
|
||
return f"image_{index}.jpg"
|
||
|
||
@staticmethod
|
||
def _guess_image_mime_type(image_ref: str, filename: Optional[str]) -> str:
|
||
"""
|
||
根据图片引用或文件名推测 MIME 类型。
|
||
"""
|
||
if image_ref and image_ref.startswith("data:"):
|
||
mime = image_ref[5:].split(";", 1)[0].strip().lower()
|
||
return mime or "image/jpeg"
|
||
guessed, _ = mimetypes.guess_type(filename or "")
|
||
if guessed and guessed.startswith("image/"):
|
||
return guessed
|
||
return "image/jpeg"
|
||
|
||
@staticmethod
|
||
def _decode_data_url_bytes(data_url: Optional[str]) -> Optional[bytes]:
|
||
"""
|
||
将 data URL 解码为原始字节。
|
||
"""
|
||
if not data_url or not data_url.startswith("data:"):
|
||
return None
|
||
try:
|
||
_, payload = data_url.split(",", 1)
|
||
except ValueError:
|
||
return None
|
||
try:
|
||
return base64.b64decode(payload)
|
||
except Exception as e:
|
||
logger.error(e)
|
||
return None
|