Merge remote-tracking branch 'origin/v2' into v2

This commit is contained in:
jxxghp
2025-11-24 21:30:07 +08:00
14 changed files with 681 additions and 437 deletions
+14 -33
View File
@@ -1,7 +1,6 @@
"""MoviePilot AI智能体实现""" """MoviePilot AI智能体实现"""
import asyncio import asyncio
import threading
from typing import Dict, List, Any from typing import Dict, List, Any
from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain.agents import AgentExecutor, create_openai_tools_agent
@@ -21,9 +20,6 @@ from app.helper.message import MessageHelper
from app.log import logger from app.log import logger
from app.schemas import Notification from app.schemas import Notification
# 用于保护环境变量修改的线程锁
_env_lock = threading.Lock()
class AgentChain(ChainBase): class AgentChain(ChainBase):
pass pass
@@ -75,36 +71,21 @@ class MoviePilotAgent:
api_key = settings.LLM_API_KEY api_key = settings.LLM_API_KEY
if provider == "google": if provider == "google":
import os
from contextlib import contextmanager
from langchain_google_genai import ChatGoogleGenerativeAI
# 使用线程锁保护的临时环境变量配置
@contextmanager
def _temp_proxy_env():
"""线程安全的临时设置代理环境变量的上下文管理器"""
with _env_lock:
old_http = os.environ.get("HTTP_PROXY")
old_https = os.environ.get("HTTPS_PROXY")
try:
if settings.PROXY_HOST: if settings.PROXY_HOST:
os.environ["HTTP_PROXY"] = settings.PROXY_HOST from langchain_openai import ChatOpenAI
os.environ["HTTPS_PROXY"] = settings.PROXY_HOST return ChatOpenAI(
yield model=settings.LLM_MODEL,
finally: api_key=api_key,
# 恢复原始环境变量 max_retries=3,
if old_http is not None: base_url="https://generativelanguage.googleapis.com/v1beta/openai",
os.environ["HTTP_PROXY"] = old_http temperature=settings.LLM_TEMPERATURE,
elif "HTTP_PROXY" in os.environ: streaming=True,
del os.environ["HTTP_PROXY"] callbacks=[self.callback_handler],
stream_usage=True,
if old_https is not None: openai_proxy=settings.PROXY_HOST
os.environ["HTTPS_PROXY"] = old_https )
elif "HTTPS_PROXY" in os.environ: else:
del os.environ["HTTPS_PROXY"] from langchain_google_genai import ChatGoogleGenerativeAI
# 在临时环境变量中初始化 ChatGoogleGenerativeAI
with _temp_proxy_env():
return ChatGoogleGenerativeAI( return ChatGoogleGenerativeAI(
model=settings.LLM_MODEL, model=settings.LLM_MODEL,
google_api_key=api_key, google_api_key=api_key,
+4
View File
@@ -21,6 +21,8 @@ from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribes
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
from app.agent.tools.impl.search_media import SearchMediaTool from app.agent.tools.impl.search_media import SearchMediaTool
from app.agent.tools.impl.search_person import SearchPersonTool
from app.agent.tools.impl.search_person_credits import SearchPersonCreditsTool
from app.agent.tools.impl.recognize_media import RecognizeMediaTool from app.agent.tools.impl.recognize_media import RecognizeMediaTool
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool
@@ -53,6 +55,8 @@ class MoviePilotToolFactory:
tools = [] tools = []
tool_definitions = [ tool_definitions = [
SearchMediaTool, SearchMediaTool,
SearchPersonTool,
SearchPersonCreditsTool,
RecognizeMediaTool, RecognizeMediaTool,
ScrapeMetadataTool, ScrapeMetadataTool,
QueryEpisodeScheduleTool, QueryEpisodeScheduleTool,
+9 -2
View File
@@ -33,6 +33,8 @@ class AddSubscribeInput(BaseModel):
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')")
filter_groups: Optional[List[str]] = Field(None, filter_groups: Optional[List[str]] = Field(None,
description="List of filter rule group names to apply (optional, use query_rule_groups tool to get available rule groups)") description="List of filter rule group names to apply (optional, use query_rule_groups tool to get available rule groups)")
sites: Optional[List[int]] = Field(None,
description="List of site IDs to search from (optional, use query_sites tool to get available site IDs)")
class AddSubscribeTool(MoviePilotTool): class AddSubscribeTool(MoviePilotTool):
@@ -61,12 +63,13 @@ class AddSubscribeTool(MoviePilotTool):
season: Optional[int] = None, tmdb_id: Optional[str] = None, season: Optional[int] = None, tmdb_id: Optional[str] = None,
start_episode: Optional[int] = None, total_episode: Optional[int] = None, start_episode: Optional[int] = None, total_episode: Optional[int] = None,
quality: Optional[str] = None, resolution: Optional[str] = None, quality: Optional[str] = None, resolution: Optional[str] = None,
effect: Optional[str] = None, filter_groups: Optional[List[str]] = None, **kwargs) -> str: effect: Optional[str] = None, filter_groups: Optional[List[str]] = None,
sites: Optional[List[int]] = None, **kwargs) -> str:
logger.info( logger.info(
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, " f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
f"season={season}, tmdb_id={tmdb_id}, start_episode={start_episode}, " f"season={season}, tmdb_id={tmdb_id}, start_episode={start_episode}, "
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, " f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
f"effect={effect}, filter_groups={filter_groups}") f"effect={effect}, filter_groups={filter_groups}, sites={sites}")
try: try:
subscribe_chain = SubscribeChain() subscribe_chain = SubscribeChain()
@@ -92,6 +95,8 @@ class AddSubscribeTool(MoviePilotTool):
subscribe_kwargs['effect'] = effect subscribe_kwargs['effect'] = effect
if filter_groups: if filter_groups:
subscribe_kwargs['filter_groups'] = filter_groups subscribe_kwargs['filter_groups'] = filter_groups
if sites:
subscribe_kwargs['sites'] = sites
sid, message = await subscribe_chain.async_add( sid, message = await subscribe_chain.async_add(
mtype=MediaType(media_type), mtype=MediaType(media_type),
@@ -118,6 +123,8 @@ class AddSubscribeTool(MoviePilotTool):
params.append(f"特效过滤: {effect}") params.append(f"特效过滤: {effect}")
if filter_groups: if filter_groups:
params.append(f"规则组: {', '.join(filter_groups)}") params.append(f"规则组: {', '.join(filter_groups)}")
if sites:
params.append(f"站点: {', '.join(map(str, sites))}")
if params: if params:
result_msg += f"\n配置参数: {', '.join(params)}" result_msg += f"\n配置参数: {', '.join(params)}"
return result_msg return result_msg
+1 -2
View File
@@ -1,7 +1,7 @@
"""查询媒体库工具""" """查询媒体库工具"""
import json import json
from typing import Optional, List, Type from typing import Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -9,7 +9,6 @@ from app.agent.tools.base import MoviePilotTool
from app.chain.mediaserver import MediaServerChain from app.chain.mediaserver import MediaServerChain
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.log import logger from app.log import logger
from app.schemas import MediaServerItem
from app.schemas.types import MediaType from app.schemas.types import MediaType
+83
View File
@@ -0,0 +1,83 @@
"""搜索人物工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.media import MediaChain
from app.log import logger
class SearchPersonInput(BaseModel):
"""搜索人物工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')")
class SearchPersonTool(MoviePilotTool):
name: str = "search_person"
description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database."
args_schema: Type[BaseModel] = SearchPersonInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息"""
name = kwargs.get("name", "")
return f"正在搜索人物: {name}"
async def run(self, name: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: name={name}")
try:
media_chain = MediaChain()
# 使用 MediaChain.async_search_persons 方法搜索人物
persons = await media_chain.async_search_persons(name=name)
if persons:
# 限制最多30条结果
total_count = len(persons)
limited_persons = persons[:30]
# 精简字段,只保留关键信息
simplified_results = []
for person in limited_persons:
simplified = {
"name": person.name,
"id": person.id,
"source": person.source,
"profile_path": person.profile_path,
"original_name": person.original_name,
"known_for_department": person.known_for_department,
"popularity": person.popularity,
"biography": person.biography[:200] + "..." if person.biography and len(person.biography) > 200 else person.biography,
"birthday": person.birthday,
"deathday": person.deathday,
"place_of_birth": person.place_of_birth,
"gender": person.gender,
"imdb_id": person.imdb_id,
"also_known_as": person.also_known_as[:5] if person.also_known_as else [], # 限制别名数量
}
# 添加豆瓣特有字段
if person.source == "douban":
simplified["url"] = person.url
simplified["avatar"] = person.avatar
simplified["latin_name"] = person.latin_name
simplified["roles"] = person.roles[:5] if person.roles else [] # 限制角色数量
# 添加Bangumi特有字段
if person.source == "bangumi":
simplified["career"] = person.career
simplified["relation"] = person.relation
simplified_results.append(simplified)
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
# 如果结果被裁剪,添加提示信息
if total_count > 30:
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
return result_json
else:
return f"未找到相关人物信息: {name}"
except Exception as e:
error_message = f"搜索人物失败: {str(e)}"
logger.error(f"搜索人物失败: {e}", exc_info=True)
return error_message
@@ -0,0 +1,85 @@
"""搜索演员参演作品工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
from app.log import logger
class SearchPersonCreditsInput(BaseModel):
"""搜索演员参演作品工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)")
source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi")
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
class SearchPersonCreditsTool(MoviePilotTool):
name: str = "search_person_credits"
description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in."
args_schema: Type[BaseModel] = SearchPersonCreditsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息"""
person_id = kwargs.get("person_id", "")
source = kwargs.get("source", "")
return f"正在搜索人物参演作品: {source} ID {person_id}"
async def run(self, person_id: int, source: str, page: Optional[int] = 1, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: person_id={person_id}, source={source}, page={page}")
try:
# 根据source选择相应的chain
if source.lower() == "tmdb":
tmdb_chain = TmdbChain()
medias = await tmdb_chain.async_person_credits(person_id=person_id, page=page)
elif source.lower() == "douban":
douban_chain = DoubanChain()
medias = await douban_chain.async_person_credits(person_id=person_id, page=page)
elif source.lower() == "bangumi":
bangumi_chain = BangumiChain()
medias = await bangumi_chain.async_person_credits(person_id=person_id)
else:
return f"不支持的数据源: {source}。支持的数据源: tmdb, douban, bangumi"
if medias:
# 限制最多30条结果
total_count = len(medias)
limited_medias = medias[:30]
# 精简字段,只保留关键信息
simplified_results = []
for media in limited_medias:
simplified = {
"title": media.title,
"en_title": media.en_title,
"year": media.year,
"type": media.type.value if media.type else None,
"season": media.season,
"tmdb_id": media.tmdb_id,
"imdb_id": media.imdb_id,
"douban_id": media.douban_id,
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
"vote_average": media.vote_average,
"poster_path": media.poster_path,
"backdrop_path": media.backdrop_path,
"detail_link": media.detail_link
}
simplified_results.append(simplified)
result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)
# 如果结果被裁剪,添加提示信息
if total_count > 30:
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}"
return result_json
else:
return f"未找到人物 ID {person_id} ({source}) 的参演作品"
except Exception as e:
error_message = f"搜索演员参演作品失败: {str(e)}"
logger.error(f"搜索演员参演作品失败: {e}", exc_info=True)
return error_message
+4 -2
View File
@@ -64,7 +64,8 @@ class SearchWebTool(MoviePilotTool):
logger.error(f"搜索网络内容失败: {e}", exc_info=True) logger.error(f"搜索网络内容失败: {e}", exc_info=True)
return error_message return error_message
async def _search_duckduckgo_api(self, query: str, max_results: int) -> list: @staticmethod
async def _search_duckduckgo_api(query: str, max_results: int) -> list:
""" """
使用DuckDuckGo API进行搜索 使用DuckDuckGo API进行搜索
@@ -143,7 +144,8 @@ class SearchWebTool(MoviePilotTool):
logger.warning(f"DuckDuckGo API搜索失败: {e}") logger.warning(f"DuckDuckGo API搜索失败: {e}")
return [] return []
def _format_and_truncate_results(self, results: list, max_results: int) -> dict: @staticmethod
def _format_and_truncate_results(results: list, max_results: int) -> dict:
""" """
格式化并裁剪搜索结果以避免占用过多上下文 格式化并裁剪搜索结果以避免占用过多上下文
+15 -14
View File
@@ -85,26 +85,27 @@ async def search(title: str,
return obj.get("source") return obj.get("source")
return obj.source return obj.source
result = []
media_chain = MediaChain() media_chain = MediaChain()
if type == "media": if type == "media":
_, medias = await media_chain.async_search(title=title) _, medias = await media_chain.async_search(title=title)
if medias: result = [media.to_dict() for media in medias] if medias else []
result = [media.to_dict() for media in medias]
elif type == "collection": elif type == "collection":
result = await media_chain.async_search_collections(name=title) collections = await media_chain.async_search_collections(name=title)
else: result = [collection.to_dict() for collection in collections] if collections else []
result = await media_chain.async_search_persons(name=title) else: # person
if result: persons = await media_chain.async_search_persons(name=title)
# 按设置的顺序对结果进行排序 result = [person.model_dump() for person in persons] if persons else []
setting_order = settings.SEARCH_SOURCE.split(',') or []
sort_order = {} if not result:
for index, source in enumerate(setting_order):
sort_order[source] = index
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
return result[(page - 1) * count:page * count]
return [] return []
# 排序和分页
setting_order = settings.SEARCH_SOURCE.split(',') if settings.SEARCH_SOURCE else []
sort_order = {source: index for index, source in enumerate(setting_order)}
sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
return sorted_result[(page - 1) * count:page * count]
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response) @router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
def scrape(fileitem: schemas.FileItem, def scrape(fileitem: schemas.FileItem,
+18 -9
View File
@@ -164,19 +164,15 @@ class MessageChain(ChainBase):
) )
# 处理消息 # 处理消息
if text.startswith('CALLBACK:'): if text.startswith('CALLBACK:'):
# 处理按钮回调(适配支持回调的渠 # 处理按钮回调(适配支持回调的渠),优先级最高
if ChannelCapabilityManager.supports_callbacks(channel): if ChannelCapabilityManager.supports_callbacks(channel):
self._handle_callback(text=text, channel=channel, source=source, self._handle_callback(text=text, channel=channel, source=source,
userid=userid, username=username, userid=userid, username=username,
original_message_id=original_message_id, original_chat_id=original_chat_id) original_message_id=original_message_id, original_chat_id=original_chat_id)
else: else:
logger.warning(f"渠道 {channel.value} 不支持回调,但收到了回调消息:{text}") logger.warning(f"渠道 {channel.value} 不支持回调,但收到了回调消息:{text}")
elif text.startswith('/ai') or text.startswith('/AI'): elif text.startswith('/') and not text.lower().startswith('/ai'):
# AI智能体处理 # 执行特定命令命令(但不是/ai
self._handle_ai_message(text=text, channel=channel, source=source,
userid=userid, username=username)
elif text.startswith('/'):
# 执行命令
self.eventmanager.send_event( self.eventmanager.send_event(
EventType.CommandExcute, EventType.CommandExcute,
{ {
@@ -186,7 +182,17 @@ class MessageChain(ChainBase):
"source": source "source": source
} }
) )
elif text.isdigit(): elif text.lower().startswith('/ai'):
# 用户指定AI智能体消息响应
self._handle_ai_message(text=text, channel=channel, source=source,
userid=userid, username=username)
elif settings.AI_AGENT_ENABLE and settings.AI_AGENT_GLOBAL:
# 普通消息,全局智能体响应
self._handle_ai_message(text=text, channel=channel, source=source,
userid=userid, username=username)
else:
# 非智能体普通消息响应
if text.isdigit():
# 用户选择了具体的条目 # 用户选择了具体的条目
# 缓存 # 缓存
cache_data: dict = user_cache.get(userid).copy() cache_data: dict = user_cache.get(userid).copy()
@@ -926,7 +932,10 @@ class MessageChain(ChainBase):
return return
# 提取用户消息 # 提取用户消息
user_message = text[3:].strip() # 移除 "/ai" 前缀 if text.lower().startswith("/ai"):
user_message = text[3:].strip() # 移除 "/ai" 前缀(大小写不敏感)
else:
user_message = text.strip() # 按原消息处理
if not user_message: if not user_message:
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
+2 -6
View File
@@ -1024,13 +1024,11 @@ def fresh(fresh: bool = True):
with fresh(): with fresh():
result = some_cached_function() result = some_cached_function()
""" """
token = _fresh.set(fresh) token = _fresh.set(fresh or is_fresh())
logger.debug(f"Setting fresh mode to {fresh}. {id(token):#x}")
try: try:
yield yield
finally: finally:
_fresh.reset(token) _fresh.reset(token)
logger.debug(f"Reset fresh mode. {id(token):#x}")
@asynccontextmanager @asynccontextmanager
async def async_fresh(fresh: bool = True): async def async_fresh(fresh: bool = True):
@@ -1041,13 +1039,11 @@ async def async_fresh(fresh: bool = True):
async with async_fresh(): async with async_fresh():
result = await some_async_cached_function() result = await some_async_cached_function()
""" """
token = _fresh.set(fresh) token = _fresh.set(fresh or is_fresh())
logger.debug(f"Setting async_fresh mode to {fresh}. {id(token):#x}")
try: try:
yield yield
finally: finally:
_fresh.reset(token) _fresh.reset(token)
logger.debug(f"Reset async_fresh mode. {id(token):#x}")
def is_fresh() -> bool: def is_fresh() -> bool:
""" """
+2
View File
@@ -411,6 +411,8 @@ class ConfigModel(BaseModel):
# ==================== AI智能体配置 ==================== # ==================== AI智能体配置 ====================
# AI智能体开关 # AI智能体开关
AI_AGENT_ENABLE: bool = False AI_AGENT_ENABLE: bool = False
# 合局AI智能体
AI_AGENT_GLOBAL: bool = False
# LLM提供商 (openai/google/deepseek) # LLM提供商 (openai/google/deepseek)
LLM_PROVIDER: str = "deepseek" LLM_PROVIDER: str = "deepseek"
# LLM模型名称 # LLM模型名称
+123 -48
View File
@@ -1,19 +1,21 @@
import asyncio
import re import re
import threading import threading
import uuid
from pathlib import Path
from threading import Event from threading import Event
from typing import Optional, List, Dict, Callable from typing import Optional, List, Dict, Callable
from urllib.parse import urljoin from urllib.parse import urljoin
import telebot import telebot
from telegramify_markdown import standardize, telegramify
from telegramify_markdown.type import ContentTypes, SentType
from telebot import apihelper from telebot import apihelper
from telebot.types import InputFile, InlineKeyboardMarkup, InlineKeyboardButton from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
from telebot.types import InputMediaPhoto from telebot.types import InputMediaPhoto
from app.core.config import settings from app.core.config import settings
from app.core.context import MediaInfo, Context from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.helper.thread import ThreadHelper
from app.log import logger from app.log import logger
from app.utils.common import retry from app.utils.common import retry
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
@@ -52,7 +54,7 @@ class Telegram:
else: else:
apihelper.proxy = settings.PROXY apihelper.proxy = settings.PROXY
# bot # bot
_bot = telebot.TeleBot(self._telegram_token, parse_mode="Markdown") _bot = telebot.TeleBot(self._telegram_token, parse_mode="MarkdownV2")
# 记录句柄 # 记录句柄
self._bot = _bot self._bot = _bot
# 获取并存储bot用户名用于@检测 # 获取并存储bot用户名用于@检测
@@ -236,12 +238,14 @@ class Telegram:
return False return False
try: try:
if text: if title and text:
# 对text进行Markdown特殊字符转义 caption = f"**{title}**\n{text}"
text = re.sub(r"([_`])", r"\\\1", text) elif title:
caption = f"*{title}*\n{text}" caption = f"**{title}**"
elif text:
caption = text
else: else:
caption = f"*{title}*" caption = ""
if link: if link:
caption = f"{caption}\n[查看详情]({link})" caption = f"{caption}\n[查看详情]({link})"
@@ -499,7 +503,7 @@ class Telegram:
if image: if image:
# 如果有图片,使用edit_message_media # 如果有图片,使用edit_message_media
media = InputMediaPhoto(media=image, caption=text, parse_mode="Markdown") media = InputMediaPhoto(media=image, caption=standardize(text), parse_mode="MarkdownV2")
self._bot.edit_message_media( self._bot.edit_message_media(
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
@@ -511,8 +515,8 @@ class Telegram:
self._bot.edit_message_text( self._bot.edit_message_text(
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
text=text, text=standardize(text),
parse_mode="Markdown", parse_mode="MarkdownV2",
reply_markup=reply_markup reply_markup=reply_markup
) )
return True return True
@@ -520,49 +524,120 @@ class Telegram:
logger.error(f"编辑消息失败:{str(e)}") logger.error(f"编辑消息失败:{str(e)}")
return False return False
@retry(RetryException, logger=logger)
def __send_request(self, userid: Optional[str] = None, image="", caption="", def __send_request(self, userid: Optional[str] = None, image="", caption="",
reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool: reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool:
""" """
向Telegram发送报文 向Telegram发送报文
:param reply_markup: 内联键盘 :param reply_markup: 内联键盘
""" """
if image: kwargs = {
res = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT).get_res(image) 'chat_id': userid or self._telegram_chat_id,
if res is None: 'parse_mode': "MarkdownV2",
raise Exception("获取图片失败") 'reply_markup': reply_markup
if res.content: }
# 使用随机标识构建图片文件的完整路径,并写入图片内容到文件
image_file = Path(settings.TEMP_PATH) / "telegram" / str(uuid.uuid4()) try:
if not image_file.parent.exists(): # 处理图片
image_file.parent.mkdir(parents=True, exist_ok=True) image = self.__process_image(image) if image else None
image_file.write_bytes(res.content)
photo = InputFile(image_file) # 图片消息的标题长度限制为1024,文本消息为4096
# 发送图片到Telegram caption_limit = 1024 if image else 4096
ret = self._bot.send_photo(chat_id=userid or self._telegram_chat_id, if len(caption) < caption_limit:
photo=photo, ret = self.__send_short_message(image, caption, **kwargs)
caption=caption,
parse_mode="Markdown",
reply_markup=reply_markup)
if ret is None:
raise RetryException("发送图片消息失败")
return True
# 按4096分段循环发送消息
ret = None
if len(caption) > 4095:
for i in range(0, len(caption), 4095):
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
text=caption[i:i + 4095],
parse_mode="Markdown",
reply_markup=reply_markup if i == 0 else None)
else: else:
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id, sent_idx = set()
text=caption, ret = self.__send_long_message(image, caption, sent_idx, **kwargs)
parse_mode="Markdown",
reply_markup=reply_markup) return ret is not None
if ret is None: except Exception as e:
raise RetryException("发送文本消息失败") logger.error(f"发送Telegram消息失败: {e}")
return True if ret else False return False
@retry(RetryException, logger=logger)
def __process_image(self, image_url: str) -> bytes:
"""
处理图片URL,获取图片内容
"""
try:
res = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT
).get_res(image_url)
if not res or not res.content:
raise RetryException("获取图片失败")
return res.content
except Exception as e:
raise
@retry(RetryException, logger=logger)
def __send_short_message(self, image: Optional[bytes], caption: str, **kwargs):
"""
发送短消息
"""
try:
if image:
return self._bot.send_photo(
photo=image,
caption=standardize(caption),
**kwargs
)
else:
return self._bot.send_message(
text=standardize(caption),
**kwargs
)
except Exception as e:
raise RetryException(f"发送{'图片' if image else '文本'}消息失败")
@retry(RetryException, logger=logger)
def __send_long_message(self, image: Optional[bytes], caption: str, sent_idx: set, **kwargs):
"""
发送长消息
"""
try:
reply_markup = kwargs.pop("reply_markup", None)
boxs: SentType = ThreadHelper().submit(lambda x: asyncio.run(telegramify(x)), caption).result()
ret = None
for i, item in enumerate(boxs):
if i in sent_idx:
# 跳过已发送消息
continue
current_reply_markup = reply_markup if i == 0 else None
if item.content_type == ContentTypes.TEXT and (i != 0 or not image):
ret = self._bot.send_message(**kwargs,
text=item.content,
reply_markup=current_reply_markup
)
elif item.content_type == ContentTypes.PHOTO or (image and i == 0):
ret = self._bot.send_photo(**kwargs,
photo=(getattr(item, "file_name", ""),
getattr(item, "file_data", image)),
caption=getattr(item, "caption", item.content),
reply_markup=current_reply_markup
)
elif item.content_type == ContentTypes.FILE:
ret = self._bot.send_document(**kwargs,
document=(item.file_name, item.file_data),
caption=item.caption,
reply_markup=current_reply_markup
)
sent_idx.add(i)
return ret
except Exception as e:
try:
raise RetryException(f"消息 [{i + 1}/{len(boxs)}] 发送失败") from e
except NameError:
raise RetryException("发送长消息失败") from e
def register_commands(self, commands: Dict[str, dict]): def register_commands(self, commands: Dict[str, dict]):
""" """
+1 -1
View File
@@ -14,7 +14,7 @@ class CommingMessage(BaseModel):
# 用户ID # 用户ID
userid: Optional[Union[str, int]] = None userid: Optional[Union[str, int]] = None
# 用户名称 # 用户名称
username: Optional[str] = None username: Optional[Union[str, int]] = None
# 消息渠道 # 消息渠道
channel: Optional[MessageChannel] = None channel: Optional[MessageChannel] = None
# 来源(渠道名称) # 来源(渠道名称)
+2 -2
View File
@@ -1,2 +1,2 @@
APP_VERSION = 'v2.8.5' APP_VERSION = 'v2.8.6'
FRONTEND_VERSION = 'v2.8.5' FRONTEND_VERSION = 'v2.8.6'