mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
Merge remote-tracking branch 'origin/v2' into v2
This commit is contained in:
+15
-34
@@ -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
|
if settings.PROXY_HOST:
|
||||||
from contextlib import contextmanager
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
return ChatOpenAI(
|
||||||
|
model=settings.LLM_MODEL,
|
||||||
# 使用线程锁保护的临时环境变量配置
|
api_key=api_key,
|
||||||
@contextmanager
|
max_retries=3,
|
||||||
def _temp_proxy_env():
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
"""线程安全的临时设置代理环境变量的上下文管理器"""
|
temperature=settings.LLM_TEMPERATURE,
|
||||||
with _env_lock:
|
streaming=True,
|
||||||
old_http = os.environ.get("HTTP_PROXY")
|
callbacks=[self.callback_handler],
|
||||||
old_https = os.environ.get("HTTPS_PROXY")
|
stream_usage=True,
|
||||||
try:
|
openai_proxy=settings.PROXY_HOST
|
||||||
if settings.PROXY_HOST:
|
)
|
||||||
os.environ["HTTP_PROXY"] = settings.PROXY_HOST
|
else:
|
||||||
os.environ["HTTPS_PROXY"] = settings.PROXY_HOST
|
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
# 恢复原始环境变量
|
|
||||||
if old_http is not None:
|
|
||||||
os.environ["HTTP_PROXY"] = old_http
|
|
||||||
elif "HTTP_PROXY" in os.environ:
|
|
||||||
del os.environ["HTTP_PROXY"]
|
|
||||||
|
|
||||||
if old_https is not None:
|
|
||||||
os.environ["HTTPS_PROXY"] = old_https
|
|
||||||
elif "HTTPS_PROXY" in os.environ:
|
|
||||||
del os.environ["HTTPS_PROXY"]
|
|
||||||
|
|
||||||
# 在临时环境变量中初始化 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
格式化并裁剪搜索结果以避免占用过多上下文
|
格式化并裁剪搜索结果以避免占用过多上下文
|
||||||
|
|
||||||
|
|||||||
+16
-15
@@ -85,25 +85,26 @@ 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):
|
return []
|
||||||
sort_order[source] = index
|
|
||||||
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
|
# 排序和分页
|
||||||
return result[(page - 1) * count:page * count]
|
setting_order = settings.SEARCH_SOURCE.split(',') if settings.SEARCH_SOURCE else []
|
||||||
return []
|
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)
|
||||||
|
|||||||
+333
-324
@@ -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,266 +182,226 @@ 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,
|
||||||
cache_data: dict = user_cache.get(userid).copy()
|
userid=userid, username=username)
|
||||||
# 选择项目
|
elif settings.AI_AGENT_ENABLE and settings.AI_AGENT_GLOBAL:
|
||||||
if not cache_data \
|
# 普通消息,全局智能体响应
|
||||||
or not cache_data.get('items') \
|
self._handle_ai_message(text=text, channel=channel, source=source,
|
||||||
or len(cache_data.get('items')) < int(text):
|
userid=userid, username=username)
|
||||||
# 发送消息
|
else:
|
||||||
self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid))
|
# 非智能体普通消息响应
|
||||||
return
|
if text.isdigit():
|
||||||
try:
|
# 用户选择了具体的条目
|
||||||
# 选择的序号
|
# 缓存
|
||||||
_choice = int(text) + _current_page * self._page_size - 1
|
cache_data: dict = user_cache.get(userid).copy()
|
||||||
# 缓存类型
|
# 选择项目
|
||||||
cache_type: str = cache_data.get('type')
|
if not cache_data \
|
||||||
# 缓存列表
|
or not cache_data.get('items') \
|
||||||
cache_list: list = cache_data.get('items').copy()
|
or len(cache_data.get('items')) < int(text):
|
||||||
# 选择
|
# 发送消息
|
||||||
|
self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
if cache_type in ["Search", "ReSearch"]:
|
# 选择的序号
|
||||||
# 当前媒体信息
|
_choice = int(text) + _current_page * self._page_size - 1
|
||||||
mediainfo: MediaInfo = cache_list[_choice]
|
# 缓存类型
|
||||||
_current_media = mediainfo
|
cache_type: str = cache_data.get('type')
|
||||||
# 查询缺失的媒体信息
|
# 缓存列表
|
||||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=_current_meta,
|
cache_list: list = cache_data.get('items').copy()
|
||||||
mediainfo=_current_media)
|
# 选择
|
||||||
if exist_flag and cache_type == "Search":
|
try:
|
||||||
# 媒体库中已存在
|
if cache_type in ["Search", "ReSearch"]:
|
||||||
|
# 当前媒体信息
|
||||||
|
mediainfo: MediaInfo = cache_list[_choice]
|
||||||
|
_current_media = mediainfo
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=_current_meta,
|
||||||
|
mediainfo=_current_media)
|
||||||
|
if exist_flag and cache_type == "Search":
|
||||||
|
# 媒体库中已存在
|
||||||
|
self.post_message(
|
||||||
|
Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=f"【{_current_media.title_year}"
|
||||||
|
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】",
|
||||||
|
userid=userid))
|
||||||
|
return
|
||||||
|
elif exist_flag:
|
||||||
|
# 没有缺失,但要全量重新搜索和下载
|
||||||
|
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
||||||
|
# 发送缺失的媒体信息
|
||||||
|
messages = []
|
||||||
|
if no_exists and cache_type == "Search":
|
||||||
|
# 发送缺失消息
|
||||||
|
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||||
|
messages = [
|
||||||
|
f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集"
|
||||||
|
for sea, no_exist in no_exists.get(mediakey).items()]
|
||||||
|
elif no_exists:
|
||||||
|
# 发送总集数的消息
|
||||||
|
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||||
|
messages = [
|
||||||
|
f"第 {sea} 季总 {no_exist.total_episode} 集"
|
||||||
|
for sea, no_exist in no_exists.get(mediakey).items()]
|
||||||
|
if messages:
|
||||||
|
self.post_message(Notification(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
||||||
|
userid=userid))
|
||||||
|
# 搜索种子,过滤掉不需要的剧集,以便选择
|
||||||
|
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(channel=channel,
|
Notification(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title=f"【{_current_media.title_year}"
|
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
||||||
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】",
|
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return
|
# 开始搜索
|
||||||
elif exist_flag:
|
contexts = SearchChain().process(mediainfo=mediainfo,
|
||||||
# 没有缺失,但要全量重新搜索和下载
|
no_exists=no_exists)
|
||||||
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
if not contexts:
|
||||||
# 发送缺失的媒体信息
|
# 没有数据
|
||||||
messages = []
|
|
||||||
if no_exists and cache_type == "Search":
|
|
||||||
# 发送缺失消息
|
|
||||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
|
||||||
messages = [
|
|
||||||
f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集"
|
|
||||||
for sea, no_exist in no_exists.get(mediakey).items()]
|
|
||||||
elif no_exists:
|
|
||||||
# 发送总集数的消息
|
|
||||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
|
||||||
messages = [
|
|
||||||
f"第 {sea} 季总 {no_exist.total_episode} 集"
|
|
||||||
for sea, no_exist in no_exists.get(mediakey).items()]
|
|
||||||
if messages:
|
|
||||||
self.post_message(Notification(channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
|
||||||
userid=userid))
|
|
||||||
# 搜索种子,过滤掉不需要的剧集,以便选择
|
|
||||||
logger.info(f"开始搜索 {mediainfo.title_year} ...")
|
|
||||||
self.post_message(
|
|
||||||
Notification(channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
|
||||||
userid=userid))
|
|
||||||
# 开始搜索
|
|
||||||
contexts = SearchChain().process(mediainfo=mediainfo,
|
|
||||||
no_exists=no_exists)
|
|
||||||
if not contexts:
|
|
||||||
# 没有数据
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=f"{mediainfo.title}"
|
|
||||||
f"{_current_meta.sea} 未搜索到需要的资源!",
|
|
||||||
userid=userid))
|
|
||||||
return
|
|
||||||
# 搜索结果排序
|
|
||||||
contexts = TorrentHelper().sort_torrents(contexts)
|
|
||||||
try:
|
|
||||||
# 判断是否设置自动下载
|
|
||||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
|
||||||
# 匹配到自动下载用户
|
|
||||||
if auto_download_user \
|
|
||||||
and (auto_download_user == "all"
|
|
||||||
or any(userid == user for user in auto_download_user.split(","))):
|
|
||||||
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
|
|
||||||
# 自动选择下载
|
|
||||||
self.__auto_download(channel=channel,
|
|
||||||
source=source,
|
|
||||||
cache_list=contexts,
|
|
||||||
userid=userid,
|
|
||||||
username=username,
|
|
||||||
no_exists=no_exists)
|
|
||||||
else:
|
|
||||||
# 更新缓存
|
|
||||||
user_cache[userid] = {
|
|
||||||
"type": "Torrent",
|
|
||||||
"items": contexts
|
|
||||||
}
|
|
||||||
_current_page = 0
|
|
||||||
# 保存缓存
|
|
||||||
self.save_cache(user_cache, self._cache_file)
|
|
||||||
# 删除原消息
|
|
||||||
if (original_message_id and original_chat_id and
|
|
||||||
ChannelCapabilityManager.supports_deletion(channel)):
|
|
||||||
self.delete_message(
|
|
||||||
channel=channel,
|
|
||||||
source=source,
|
|
||||||
message_id=original_message_id,
|
|
||||||
chat_id=original_chat_id
|
|
||||||
)
|
|
||||||
# 发送种子数据
|
|
||||||
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
|
||||||
self.__post_torrents_message(channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=mediainfo.title,
|
|
||||||
items=contexts[:self._page_size],
|
|
||||||
userid=userid,
|
|
||||||
total=len(contexts))
|
|
||||||
finally:
|
|
||||||
contexts.clear()
|
|
||||||
del contexts
|
|
||||||
elif cache_type in ["Subscribe", "ReSubscribe"]:
|
|
||||||
# 订阅或洗版媒体
|
|
||||||
mediainfo: MediaInfo = cache_list[_choice]
|
|
||||||
# 洗版标识
|
|
||||||
best_version = False
|
|
||||||
# 查询缺失的媒体信息
|
|
||||||
if cache_type == "Subscribe":
|
|
||||||
exist_flag, _ = DownloadChain().get_no_exists_info(meta=_current_meta,
|
|
||||||
mediainfo=mediainfo)
|
|
||||||
if exist_flag:
|
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title=f"【{mediainfo.title_year}"
|
title=f"{mediainfo.title}"
|
||||||
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】",
|
f"{_current_meta.sea} 未搜索到需要的资源!",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return
|
return
|
||||||
else:
|
# 搜索结果排序
|
||||||
best_version = True
|
contexts = TorrentHelper().sort_torrents(contexts)
|
||||||
# 转换用户名
|
try:
|
||||||
mp_name = UserOper().get_name(
|
# 判断是否设置自动下载
|
||||||
**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||||
# 添加订阅,状态为N
|
# 匹配到自动下载用户
|
||||||
SubscribeChain().add(title=mediainfo.title,
|
if auto_download_user \
|
||||||
year=mediainfo.year,
|
and (auto_download_user == "all"
|
||||||
mtype=mediainfo.type,
|
or any(userid == user for user in auto_download_user.split(","))):
|
||||||
tmdbid=mediainfo.tmdb_id,
|
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
|
||||||
season=_current_meta.begin_season,
|
# 自动选择下载
|
||||||
channel=channel,
|
self.__auto_download(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
cache_list=contexts,
|
||||||
username=mp_name or username,
|
userid=userid,
|
||||||
best_version=best_version)
|
username=username,
|
||||||
elif cache_type == "Torrent":
|
no_exists=no_exists)
|
||||||
if int(text) == 0:
|
else:
|
||||||
# 自动选择下载,强制下载模式
|
# 更新缓存
|
||||||
self.__auto_download(channel=channel,
|
user_cache[userid] = {
|
||||||
|
"type": "Torrent",
|
||||||
|
"items": contexts
|
||||||
|
}
|
||||||
|
_current_page = 0
|
||||||
|
# 保存缓存
|
||||||
|
self.save_cache(user_cache, self._cache_file)
|
||||||
|
# 删除原消息
|
||||||
|
if (original_message_id and original_chat_id and
|
||||||
|
ChannelCapabilityManager.supports_deletion(channel)):
|
||||||
|
self.delete_message(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
message_id=original_message_id,
|
||||||
|
chat_id=original_chat_id
|
||||||
|
)
|
||||||
|
# 发送种子数据
|
||||||
|
logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...")
|
||||||
|
self.__post_torrents_message(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=mediainfo.title,
|
||||||
|
items=contexts[:self._page_size],
|
||||||
|
userid=userid,
|
||||||
|
total=len(contexts))
|
||||||
|
finally:
|
||||||
|
contexts.clear()
|
||||||
|
del contexts
|
||||||
|
elif cache_type in ["Subscribe", "ReSubscribe"]:
|
||||||
|
# 订阅或洗版媒体
|
||||||
|
mediainfo: MediaInfo = cache_list[_choice]
|
||||||
|
# 洗版标识
|
||||||
|
best_version = False
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
if cache_type == "Subscribe":
|
||||||
|
exist_flag, _ = DownloadChain().get_no_exists_info(meta=_current_meta,
|
||||||
|
mediainfo=mediainfo)
|
||||||
|
if exist_flag:
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=f"【{mediainfo.title_year}"
|
||||||
|
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】",
|
||||||
|
userid=userid))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
best_version = True
|
||||||
|
# 转换用户名
|
||||||
|
mp_name = UserOper().get_name(
|
||||||
|
**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||||
|
# 添加订阅,状态为N
|
||||||
|
SubscribeChain().add(title=mediainfo.title,
|
||||||
|
year=mediainfo.year,
|
||||||
|
mtype=mediainfo.type,
|
||||||
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
season=_current_meta.begin_season,
|
||||||
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
cache_list=cache_list,
|
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username)
|
username=mp_name or username,
|
||||||
else:
|
best_version=best_version)
|
||||||
# 下载种子
|
elif cache_type == "Torrent":
|
||||||
context: Context = cache_list[_choice]
|
if int(text) == 0:
|
||||||
# 下载
|
# 自动选择下载,强制下载模式
|
||||||
DownloadChain().download_single(context, channel=channel, source=source,
|
self.__auto_download(channel=channel,
|
||||||
userid=userid, username=username)
|
source=source,
|
||||||
|
cache_list=cache_list,
|
||||||
|
userid=userid,
|
||||||
|
username=username)
|
||||||
|
else:
|
||||||
|
# 下载种子
|
||||||
|
context: Context = cache_list[_choice]
|
||||||
|
# 下载
|
||||||
|
DownloadChain().download_single(context, channel=channel, source=source,
|
||||||
|
userid=userid, username=username)
|
||||||
|
finally:
|
||||||
|
cache_list.clear()
|
||||||
|
del cache_list
|
||||||
finally:
|
finally:
|
||||||
cache_list.clear()
|
cache_data.clear()
|
||||||
del cache_list
|
del cache_data
|
||||||
finally:
|
elif text.lower() == "p":
|
||||||
cache_data.clear()
|
# 上一页
|
||||||
del cache_data
|
cache_data: dict = user_cache.get(userid).copy()
|
||||||
elif text.lower() == "p":
|
if not cache_data:
|
||||||
# 上一页
|
# 没有缓存
|
||||||
cache_data: dict = user_cache.get(userid).copy()
|
|
||||||
if not cache_data:
|
|
||||||
# 没有缓存
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel, source=source, title="输入有误!", userid=userid))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if _current_page == 0:
|
|
||||||
# 第一页
|
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
channel=channel, source=source, title="已经是第一页了!", userid=userid))
|
channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
return
|
return
|
||||||
# 减一页
|
|
||||||
_current_page -= 1
|
|
||||||
cache_type: str = cache_data.get('type')
|
|
||||||
# 产生副本,避免修改原值
|
|
||||||
cache_list: list = cache_data.get('items').copy()
|
|
||||||
try:
|
try:
|
||||||
if _current_page == 0:
|
if _current_page == 0:
|
||||||
start = 0
|
# 第一页
|
||||||
end = self._page_size
|
self.post_message(Notification(
|
||||||
else:
|
channel=channel, source=source, title="已经是第一页了!", userid=userid))
|
||||||
start = _current_page * self._page_size
|
return
|
||||||
end = start + self._page_size
|
# 减一页
|
||||||
if cache_type == "Torrent":
|
_current_page -= 1
|
||||||
# 发送种子数据
|
cache_type: str = cache_data.get('type')
|
||||||
self.__post_torrents_message(channel=channel,
|
# 产生副本,避免修改原值
|
||||||
source=source,
|
cache_list: list = cache_data.get('items').copy()
|
||||||
title=_current_media.title,
|
|
||||||
items=cache_list[start:end],
|
|
||||||
userid=userid,
|
|
||||||
total=len(cache_list),
|
|
||||||
original_message_id=original_message_id,
|
|
||||||
original_chat_id=original_chat_id)
|
|
||||||
else:
|
|
||||||
# 发送媒体数据
|
|
||||||
self.__post_medias_message(channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=_current_meta.name,
|
|
||||||
items=cache_list[start:end],
|
|
||||||
userid=userid,
|
|
||||||
total=len(cache_list),
|
|
||||||
original_message_id=original_message_id,
|
|
||||||
original_chat_id=original_chat_id)
|
|
||||||
finally:
|
|
||||||
cache_list.clear()
|
|
||||||
del cache_list
|
|
||||||
finally:
|
|
||||||
cache_data.clear()
|
|
||||||
del cache_data
|
|
||||||
elif text.lower() == "n":
|
|
||||||
# 下一页
|
|
||||||
cache_data: dict = user_cache.get(userid).copy()
|
|
||||||
if not cache_data:
|
|
||||||
# 没有缓存
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel, source=source, title="输入有误!", userid=userid))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
cache_type: str = cache_data.get('type')
|
|
||||||
# 产生副本,避免修改原值
|
|
||||||
cache_list: list = cache_data.get('items').copy()
|
|
||||||
total = len(cache_list)
|
|
||||||
# 加一页
|
|
||||||
cache_list = cache_list[(_current_page + 1) * self._page_size:(_current_page + 2) * self._page_size]
|
|
||||||
if not cache_list:
|
|
||||||
# 没有数据
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel, source=source, title="已经是最后一页了!", userid=userid))
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
# 加一页
|
if _current_page == 0:
|
||||||
_current_page += 1
|
start = 0
|
||||||
|
end = self._page_size
|
||||||
|
else:
|
||||||
|
start = _current_page * self._page_size
|
||||||
|
end = start + self._page_size
|
||||||
if cache_type == "Torrent":
|
if cache_type == "Torrent":
|
||||||
# 发送种子数据
|
# 发送种子数据
|
||||||
self.__post_torrents_message(channel=channel,
|
self.__post_torrents_message(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title=_current_media.title,
|
title=_current_media.title,
|
||||||
items=cache_list,
|
items=cache_list[start:end],
|
||||||
userid=userid,
|
userid=userid,
|
||||||
total=total,
|
total=len(cache_list),
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id)
|
original_chat_id=original_chat_id)
|
||||||
else:
|
else:
|
||||||
@@ -453,94 +409,144 @@ class MessageChain(ChainBase):
|
|||||||
self.__post_medias_message(channel=channel,
|
self.__post_medias_message(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title=_current_meta.name,
|
title=_current_meta.name,
|
||||||
items=cache_list,
|
items=cache_list[start:end],
|
||||||
userid=userid,
|
userid=userid,
|
||||||
total=total,
|
total=len(cache_list),
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id)
|
original_chat_id=original_chat_id)
|
||||||
finally:
|
finally:
|
||||||
cache_list.clear()
|
cache_list.clear()
|
||||||
del cache_list
|
del cache_list
|
||||||
finally:
|
|
||||||
cache_data.clear()
|
|
||||||
del cache_data
|
|
||||||
else:
|
|
||||||
# 搜索或订阅
|
|
||||||
if text.startswith("订阅"):
|
|
||||||
# 订阅
|
|
||||||
content = re.sub(r"订阅[::\s]*", "", text)
|
|
||||||
action = "Subscribe"
|
|
||||||
elif text.startswith("洗版"):
|
|
||||||
# 洗版
|
|
||||||
content = re.sub(r"洗版[::\s]*", "", text)
|
|
||||||
action = "ReSubscribe"
|
|
||||||
elif text.startswith("搜索") or text.startswith("下载"):
|
|
||||||
# 重新搜索/下载
|
|
||||||
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
|
||||||
action = "ReSearch"
|
|
||||||
elif text.startswith("#") \
|
|
||||||
or re.search(r"^请[问帮你]", text) \
|
|
||||||
or re.search(r"[??]$", text) \
|
|
||||||
or StringUtils.count_words(text) > 10 \
|
|
||||||
or text.find("继续") != -1:
|
|
||||||
# 聊天
|
|
||||||
content = text
|
|
||||||
action = "Chat"
|
|
||||||
elif StringUtils.is_link(text):
|
|
||||||
# 链接
|
|
||||||
content = text
|
|
||||||
action = "Link"
|
|
||||||
else:
|
|
||||||
# 搜索
|
|
||||||
content = text
|
|
||||||
action = "Search"
|
|
||||||
|
|
||||||
if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]:
|
|
||||||
# 搜索
|
|
||||||
meta, medias = MediaChain().search(content)
|
|
||||||
# 识别
|
|
||||||
if not meta.name:
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel, source=source, title="无法识别输入内容!", userid=userid))
|
|
||||||
return
|
|
||||||
# 开始搜索
|
|
||||||
if not medias:
|
|
||||||
self.post_message(Notification(
|
|
||||||
channel=channel, source=source, title=f"{meta.name} 没有找到对应的媒体信息!",
|
|
||||||
userid=userid))
|
|
||||||
return
|
|
||||||
logger.info(f"搜索到 {len(medias)} 条相关媒体信息")
|
|
||||||
try:
|
|
||||||
# 记录当前状态
|
|
||||||
_current_meta = meta
|
|
||||||
# 保存缓存
|
|
||||||
user_cache[userid] = {
|
|
||||||
'type': action,
|
|
||||||
'items': medias
|
|
||||||
}
|
|
||||||
self.save_cache(user_cache, self._cache_file)
|
|
||||||
_current_page = 0
|
|
||||||
_current_media = None
|
|
||||||
# 发送媒体列表
|
|
||||||
self.__post_medias_message(channel=channel,
|
|
||||||
source=source,
|
|
||||||
title=meta.name,
|
|
||||||
items=medias[:self._page_size],
|
|
||||||
userid=userid, total=len(medias))
|
|
||||||
finally:
|
finally:
|
||||||
medias.clear()
|
cache_data.clear()
|
||||||
del medias
|
del cache_data
|
||||||
|
elif text.lower() == "n":
|
||||||
|
# 下一页
|
||||||
|
cache_data: dict = user_cache.get(userid).copy()
|
||||||
|
if not cache_data:
|
||||||
|
# 没有缓存
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel, source=source, title="输入有误!", userid=userid))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cache_type: str = cache_data.get('type')
|
||||||
|
# 产生副本,避免修改原值
|
||||||
|
cache_list: list = cache_data.get('items').copy()
|
||||||
|
total = len(cache_list)
|
||||||
|
# 加一页
|
||||||
|
cache_list = cache_list[(_current_page + 1) * self._page_size:(_current_page + 2) * self._page_size]
|
||||||
|
if not cache_list:
|
||||||
|
# 没有数据
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel, source=source, title="已经是最后一页了!", userid=userid))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# 加一页
|
||||||
|
_current_page += 1
|
||||||
|
if cache_type == "Torrent":
|
||||||
|
# 发送种子数据
|
||||||
|
self.__post_torrents_message(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=_current_media.title,
|
||||||
|
items=cache_list,
|
||||||
|
userid=userid,
|
||||||
|
total=total,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id)
|
||||||
|
else:
|
||||||
|
# 发送媒体数据
|
||||||
|
self.__post_medias_message(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=_current_meta.name,
|
||||||
|
items=cache_list,
|
||||||
|
userid=userid,
|
||||||
|
total=total,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id)
|
||||||
|
finally:
|
||||||
|
cache_list.clear()
|
||||||
|
del cache_list
|
||||||
|
finally:
|
||||||
|
cache_data.clear()
|
||||||
|
del cache_data
|
||||||
else:
|
else:
|
||||||
# 广播事件
|
# 搜索或订阅
|
||||||
self.eventmanager.send_event(
|
if text.startswith("订阅"):
|
||||||
EventType.UserMessage,
|
# 订阅
|
||||||
{
|
content = re.sub(r"订阅[::\s]*", "", text)
|
||||||
"text": content,
|
action = "Subscribe"
|
||||||
"userid": userid,
|
elif text.startswith("洗版"):
|
||||||
"channel": channel,
|
# 洗版
|
||||||
"source": source
|
content = re.sub(r"洗版[::\s]*", "", text)
|
||||||
}
|
action = "ReSubscribe"
|
||||||
)
|
elif text.startswith("搜索") or text.startswith("下载"):
|
||||||
|
# 重新搜索/下载
|
||||||
|
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
||||||
|
action = "ReSearch"
|
||||||
|
elif text.startswith("#") \
|
||||||
|
or re.search(r"^请[问帮你]", text) \
|
||||||
|
or re.search(r"[??]$", text) \
|
||||||
|
or StringUtils.count_words(text) > 10 \
|
||||||
|
or text.find("继续") != -1:
|
||||||
|
# 聊天
|
||||||
|
content = text
|
||||||
|
action = "Chat"
|
||||||
|
elif StringUtils.is_link(text):
|
||||||
|
# 链接
|
||||||
|
content = text
|
||||||
|
action = "Link"
|
||||||
|
else:
|
||||||
|
# 搜索
|
||||||
|
content = text
|
||||||
|
action = "Search"
|
||||||
|
|
||||||
|
if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]:
|
||||||
|
# 搜索
|
||||||
|
meta, medias = MediaChain().search(content)
|
||||||
|
# 识别
|
||||||
|
if not meta.name:
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel, source=source, title="无法识别输入内容!", userid=userid))
|
||||||
|
return
|
||||||
|
# 开始搜索
|
||||||
|
if not medias:
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel, source=source, title=f"{meta.name} 没有找到对应的媒体信息!",
|
||||||
|
userid=userid))
|
||||||
|
return
|
||||||
|
logger.info(f"搜索到 {len(medias)} 条相关媒体信息")
|
||||||
|
try:
|
||||||
|
# 记录当前状态
|
||||||
|
_current_meta = meta
|
||||||
|
# 保存缓存
|
||||||
|
user_cache[userid] = {
|
||||||
|
'type': action,
|
||||||
|
'items': medias
|
||||||
|
}
|
||||||
|
self.save_cache(user_cache, self._cache_file)
|
||||||
|
_current_page = 0
|
||||||
|
_current_media = None
|
||||||
|
# 发送媒体列表
|
||||||
|
self.__post_medias_message(channel=channel,
|
||||||
|
source=source,
|
||||||
|
title=meta.name,
|
||||||
|
items=medias[:self._page_size],
|
||||||
|
userid=userid, total=len(medias))
|
||||||
|
finally:
|
||||||
|
medias.clear()
|
||||||
|
del medias
|
||||||
|
else:
|
||||||
|
# 广播事件
|
||||||
|
self.eventmanager.send_event(
|
||||||
|
EventType.UserMessage,
|
||||||
|
{
|
||||||
|
"text": content,
|
||||||
|
"userid": userid,
|
||||||
|
"channel": channel,
|
||||||
|
"source": source
|
||||||
|
}
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
user_cache.clear()
|
user_cache.clear()
|
||||||
del user_cache
|
del user_cache
|
||||||
@@ -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
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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模型名称
|
||||||
|
|||||||
@@ -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,
|
else:
|
||||||
parse_mode="Markdown",
|
sent_idx = set()
|
||||||
reply_markup=reply_markup)
|
ret = self.__send_long_message(image, caption, sent_idx, **kwargs)
|
||||||
if ret is None:
|
|
||||||
raise RetryException("发送图片消息失败")
|
return ret is not None
|
||||||
return True
|
except Exception as e:
|
||||||
# 按4096分段循环发送消息
|
logger.error(f"发送Telegram消息失败: {e}")
|
||||||
ret = None
|
return False
|
||||||
if len(caption) > 4095:
|
|
||||||
for i in range(0, len(caption), 4095):
|
@retry(RetryException, logger=logger)
|
||||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
def __process_image(self, image_url: str) -> bytes:
|
||||||
text=caption[i:i + 4095],
|
"""
|
||||||
parse_mode="Markdown",
|
处理图片URL,获取图片内容
|
||||||
reply_markup=reply_markup if i == 0 else None)
|
"""
|
||||||
else:
|
try:
|
||||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
res = RequestUtils(
|
||||||
text=caption,
|
proxies=settings.PROXY,
|
||||||
parse_mode="Markdown",
|
ua=settings.NORMAL_USER_AGENT
|
||||||
reply_markup=reply_markup)
|
).get_res(image_url)
|
||||||
if ret is None:
|
|
||||||
raise RetryException("发送文本消息失败")
|
if not res or not res.content:
|
||||||
return True if ret else False
|
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]):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.8.5'
|
APP_VERSION = 'v2.8.6'
|
||||||
FRONTEND_VERSION = 'v2.8.5'
|
FRONTEND_VERSION = 'v2.8.6'
|
||||||
|
|||||||
Reference in New Issue
Block a user