mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""发送媒体文件工具"""
|
|
|
|
import contextvars
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, List, Optional, Tuple
|
|
from loguru import logger
|
|
|
|
from backend.modules.tools.base import Tool
|
|
|
|
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
|
|
|
FILE_EXTENSIONS = {
|
|
'.txt', '.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
'.ppt', '.pptx', '.zip', '.rar', '.7z',
|
|
'.mp3', '.mp4', '.avi', '.mov',
|
|
'.json', '.xml', '.csv', '.md'
|
|
}
|
|
|
|
_message_context_var: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar(
|
|
"send_media_message_context",
|
|
default=None,
|
|
)
|
|
|
|
WECOM_REPLY_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
WECOM_REPLY_IMAGE_MAX_COUNT = 10
|
|
|
|
|
|
class SendMediaTool(Tool):
|
|
"""发送媒体文件到频道工具"""
|
|
|
|
name = "send_media"
|
|
description = "Send local files or images to the current channel chat."
|
|
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"file_paths": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Local file paths."
|
|
},
|
|
"message": {
|
|
"type": "string",
|
|
"description": "Optional caption.",
|
|
"default": ""
|
|
}
|
|
},
|
|
"required": ["file_paths"]
|
|
}
|
|
|
|
def __init__(self, channel_manager=None, session_manager=None):
|
|
super().__init__()
|
|
self.channel_manager = channel_manager
|
|
self.session_manager = session_manager
|
|
self._current_session_id = None
|
|
|
|
def set_session_id(self, session_id: str):
|
|
"""设置当前会话 ID"""
|
|
self._current_session_id = session_id
|
|
|
|
def set_message_context(self, message_context: Optional[dict]) -> None:
|
|
"""设置当前入站消息上下文。"""
|
|
_message_context_var.set(message_context)
|
|
|
|
def _is_image_file(self, file_path: Path) -> bool:
|
|
"""判断是否为图片文件"""
|
|
return file_path.suffix.lower() in IMAGE_EXTENSIONS
|
|
|
|
def _is_supported_file(self, file_path: Path) -> bool:
|
|
"""判断是否为支持的文件格式"""
|
|
ext = file_path.suffix.lower()
|
|
return ext in IMAGE_EXTENSIONS or ext in FILE_EXTENSIONS
|
|
|
|
async def _prepare_media_path(self, file_path: Path, channel: str) -> Optional[str]:
|
|
"""为各渠道准备发送路径。
|
|
|
|
当前已实现渠道统一支持直接传递本地文件路径,由具体 channel 自行上传。
|
|
"""
|
|
_ = channel
|
|
return str(file_path)
|
|
|
|
|
|
async def _parse_session_info(self) -> Optional[Tuple[str, str, dict]]:
|
|
"""从会话名称解析频道和聊天 ID
|
|
|
|
会话名称格式:
|
|
- 频道会话: {channel}:{chat_id}:{timestamp}
|
|
- 多机器人会话: {channel}:{account_id}:{chat_id}:{timestamp}
|
|
- 网页会话: New Chat 2026/2/12 19:02:11 (不支持)
|
|
"""
|
|
if not self._current_session_id:
|
|
logger.warning("No session ID set")
|
|
return None
|
|
|
|
try:
|
|
from backend.database import AsyncSessionLocal
|
|
from backend.models.session import Session
|
|
from sqlalchemy import select
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
result = await db.execute(
|
|
select(Session).where(Session.id == self._current_session_id)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
|
|
if not session or not session.name:
|
|
logger.warning(f"Session not found: {self._current_session_id}")
|
|
return None
|
|
|
|
if ':' not in session.name:
|
|
logger.info(f"Non-channel session: {session.name}")
|
|
return None
|
|
|
|
parts = session.name.split(':')
|
|
raw_context = getattr(session, "channel_context", None)
|
|
channel_context = None
|
|
if raw_context:
|
|
try:
|
|
channel_context = json.loads(raw_context)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Invalid channel_context for session {session.id}")
|
|
|
|
if len(parts) >= 3:
|
|
channel = parts[0]
|
|
if channel_context and channel_context.get("chat_id"):
|
|
chat_id = str(channel_context["chat_id"])
|
|
elif len(parts) >= 4:
|
|
chat_id = parts[2]
|
|
else:
|
|
chat_id = parts[1]
|
|
|
|
valid_channels = {'feishu', 'qq', 'wechat', 'dingtalk', 'telegram', 'discord', 'wecom'}
|
|
if channel not in valid_channels:
|
|
logger.warning(f"Invalid channel: {channel}")
|
|
return None
|
|
|
|
metadata = {}
|
|
account_id = None
|
|
if channel_context:
|
|
account_id = str(channel_context.get("account_id") or "").strip() or None
|
|
elif len(parts) >= 4:
|
|
account_id = parts[1]
|
|
|
|
if account_id:
|
|
metadata["account_id"] = account_id
|
|
|
|
if channel == "wecom":
|
|
if channel_context:
|
|
metadata["wecom_session_context"] = channel_context
|
|
else:
|
|
metadata["wecom_session_context"] = {
|
|
"channel": "wecom",
|
|
"account_id": account_id or "default",
|
|
"sender_id": chat_id,
|
|
"chat_id": chat_id,
|
|
"is_group": False,
|
|
"delivery_mode": "user",
|
|
"to_user": chat_id,
|
|
"group_chat_id": None,
|
|
}
|
|
|
|
logger.info(
|
|
f"Parsed session '{session.name}' -> "
|
|
f"channel='{channel}', account_id='{account_id}', chat_id='{chat_id}'"
|
|
)
|
|
return (channel, chat_id, metadata)
|
|
|
|
logger.warning(f"Invalid session name format: {session.name}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing session info: {e}")
|
|
return None
|
|
|
|
def _handle_web_mode(self, file_paths: List[str], message: str) -> str:
|
|
"""网页版模式下,只返回消息文本。图片由前端的 file_paths 参数渲染逻辑处理。"""
|
|
invalid_files = []
|
|
|
|
for path in file_paths:
|
|
file_path = Path(path)
|
|
if not file_path.exists():
|
|
invalid_files.append(f"{path} (不存在)")
|
|
|
|
if invalid_files:
|
|
return f"{message}\n\n跳过 {len(invalid_files)} 个无效文件: {', '.join(invalid_files)}" if message else f"跳过 {len(invalid_files)} 个无效文件: {', '.join(invalid_files)}"
|
|
|
|
return message if message else "已准备文件"
|
|
|
|
async def execute(self, file_paths: List[str], message: str = "") -> str:
|
|
"""执行发送媒体文件"""
|
|
try:
|
|
if not self.channel_manager:
|
|
return "错误:频道管理器未初始化"
|
|
|
|
session_info = await self._parse_session_info()
|
|
if not session_info:
|
|
return self._handle_web_mode(file_paths, message)
|
|
|
|
channel, chat_id, metadata = session_info
|
|
logger.info(f"Sending media to {channel}:{chat_id}")
|
|
|
|
valid_paths = []
|
|
invalid_files = []
|
|
image_count = 0
|
|
file_count = 0
|
|
|
|
for path in file_paths:
|
|
file_path = Path(path)
|
|
if not file_path.exists():
|
|
invalid_files.append(f"{path} (不存在)")
|
|
continue
|
|
|
|
if not self._is_supported_file(file_path):
|
|
invalid_files.append(f"{path} (格式不支持)")
|
|
continue
|
|
|
|
file_size = file_path.stat().st_size
|
|
if file_size > 20 * 1024 * 1024:
|
|
invalid_files.append(f"{path} (超过 20MB)")
|
|
continue
|
|
|
|
processed_path = await self._prepare_media_path(file_path, channel)
|
|
if not processed_path:
|
|
invalid_files.append(f"{path} (预处理失败)")
|
|
continue
|
|
|
|
valid_paths.append(processed_path)
|
|
if self._is_image_file(file_path):
|
|
image_count += 1
|
|
else:
|
|
file_count += 1
|
|
|
|
if not valid_paths:
|
|
error_msg = "没有有效的文件"
|
|
if invalid_files:
|
|
error_msg += f"。无效文件: {', '.join(invalid_files)}"
|
|
return error_msg
|
|
|
|
if channel == "wecom":
|
|
return self._queue_wecom_longconn_media(valid_paths, invalid_files, message)
|
|
|
|
from backend.modules.channels.base import OutboundMessage
|
|
|
|
if not message:
|
|
if image_count > 0 and file_count == 0:
|
|
message = f"发送 {image_count} 个文件"
|
|
elif file_count > 0 and image_count == 0:
|
|
message = f"发送 {file_count} 个文件"
|
|
else:
|
|
message = f"发送 {len(valid_paths)} 个文件"
|
|
|
|
outbound_msg = OutboundMessage(
|
|
channel=channel,
|
|
chat_id=chat_id,
|
|
content=message,
|
|
media=valid_paths,
|
|
metadata=metadata
|
|
)
|
|
|
|
direct_channel = self.channel_manager.get_channel(
|
|
channel,
|
|
account_id=str(metadata.get("account_id") or "") or None,
|
|
)
|
|
if direct_channel:
|
|
await direct_channel.send(outbound_msg)
|
|
else:
|
|
await self.channel_manager.send_message(outbound_msg)
|
|
|
|
logger.info(f"Successfully sent {len(valid_paths)} files to {channel}:{chat_id}")
|
|
|
|
if len(valid_paths) == 1:
|
|
file_name = Path(valid_paths[0]).name
|
|
result_msg = f"成功发送文件: {file_name}"
|
|
else:
|
|
result_msg = f"成功发送 {len(valid_paths)} 个文件到 {channel}"
|
|
|
|
if invalid_files:
|
|
result_msg += f"\n跳过 {len(invalid_files)} 个无效文件: {', '.join(invalid_files)}"
|
|
|
|
return result_msg
|
|
|
|
except Exception as e:
|
|
logger.error(f"发送文件失败: {e}")
|
|
return f"发送文件失败: {str(e)}"
|
|
|
|
def _queue_wecom_longconn_media(
|
|
self,
|
|
valid_paths: List[str],
|
|
invalid_files: List[str],
|
|
message: str = "",
|
|
) -> str:
|
|
"""企业微信长连接模式下,将图片挂到当前被动回复上下文。"""
|
|
message_context = _message_context_var.get() or {}
|
|
metadata = message_context.get("metadata")
|
|
if metadata is None:
|
|
metadata = {}
|
|
stream_handler = metadata.get("_stream_handler")
|
|
|
|
if not stream_handler:
|
|
return (
|
|
"企业微信长连接模式当前没有可用的回复上下文,"
|
|
"暂时只能在收到用户消息后的本轮回复中发送图片。"
|
|
)
|
|
|
|
image_paths = [path for path in valid_paths if self._is_image_file(Path(path))]
|
|
file_paths = [path for path in valid_paths if not self._is_image_file(Path(path))]
|
|
accepted_image_paths: List[str] = []
|
|
oversized_images: List[str] = []
|
|
|
|
for path in image_paths:
|
|
try:
|
|
if Path(path).stat().st_size > WECOM_REPLY_IMAGE_MAX_BYTES:
|
|
oversized_images.append(Path(path).name)
|
|
else:
|
|
accepted_image_paths.append(path)
|
|
except OSError:
|
|
oversized_images.append(Path(path).name)
|
|
|
|
skipped_over_limit = []
|
|
if len(accepted_image_paths) > WECOM_REPLY_IMAGE_MAX_COUNT:
|
|
skipped_over_limit = [
|
|
Path(path).name
|
|
for path in accepted_image_paths[WECOM_REPLY_IMAGE_MAX_COUNT:]
|
|
]
|
|
accepted_image_paths = accepted_image_paths[:WECOM_REPLY_IMAGE_MAX_COUNT]
|
|
|
|
if accepted_image_paths:
|
|
pending_paths = metadata.setdefault("_wecom_pending_media_paths", [])
|
|
pending_paths.extend(accepted_image_paths)
|
|
pending_text = str(message or "").strip()
|
|
if pending_text:
|
|
metadata["_wecom_pending_media_text"] = pending_text
|
|
|
|
messages: List[str] = []
|
|
if accepted_image_paths:
|
|
if len(accepted_image_paths) == 1:
|
|
messages.append(f"已加入当前回复图片: {Path(accepted_image_paths[0]).name}")
|
|
else:
|
|
messages.append(f"已加入当前回复图片 {len(accepted_image_paths)} 张")
|
|
|
|
if file_paths:
|
|
messages.append(
|
|
"企业微信长连接暂不支持回传文件,已跳过: "
|
|
+ ", ".join(Path(path).name for path in file_paths)
|
|
)
|
|
|
|
if oversized_images:
|
|
messages.append(
|
|
"以下图片超过企业微信长连接 10MB 限制,已跳过: "
|
|
+ ", ".join(oversized_images)
|
|
)
|
|
|
|
if skipped_over_limit:
|
|
messages.append(
|
|
f"企业微信长连接单次最多附带 {WECOM_REPLY_IMAGE_MAX_COUNT} 张图片,已跳过: "
|
|
+ ", ".join(skipped_over_limit)
|
|
)
|
|
|
|
if invalid_files:
|
|
messages.append(f"跳过 {len(invalid_files)} 个无效文件: {', '.join(invalid_files)}")
|
|
|
|
return "\n".join(messages) if messages else "没有可发送的图片"
|