import asyncio import concurrent.futures import copy import difflib import fcntl import importlib import json import re import sys import threading import time import traceback from base64 import b64decode from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlencode, urlparse from urllib.request import urlopen, Request as UrlRequest from fastapi import Request from app.core.config import settings from app.core.event import eventmanager from app.core.metainfo import MetaInfo from app.core.plugin import PluginManager from app.log import logger from app.plugins import _PluginBase from app.schemas.types import EventType from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain from app.chain.subscribe import SubscribeChain from app.scheduler import Scheduler from app.utils.string import StringUtils from app.utils.http import RequestUtils for _plugin_dir in ( str(Path(__file__).resolve().parent), "/config/plugins/FeishuCommandBridgeLong", ): if Path(_plugin_dir).exists() and _plugin_dir not in sys.path: sys.path.insert(0, _plugin_dir) for _site_path in ( "/usr/local/lib/python3.12/site-packages", "/usr/local/lib/python3.11/site-packages", ): if Path(_site_path).exists() and _site_path not in sys.path: sys.path.append(_site_path) try: import lark_oapi as lark except Exception: lark = None class _LongConnectionRuntime: def __init__(self) -> None: self._thread: Optional[threading.Thread] = None self._lock = threading.Lock() self._fingerprint = "" self._plugin: Optional["FeishuCommandBridgeLong"] = None def start(self, plugin: "FeishuCommandBridgeLong") -> None: global lark if lark is None: try: import lark_oapi as runtime_lark lark = runtime_lark except Exception as exc: logger.error( f"[FeishuCommandBridgeLong] 缺少依赖 lark-oapi,请先安装插件依赖:{exc}" ) return if not plugin._enabled or not plugin._app_id or not plugin._app_secret: return fingerprint = plugin._connection_fingerprint() with self._lock: self._plugin = plugin if self._thread and self._thread.is_alive(): if fingerprint != self._fingerprint: logger.warning( "[FeishuCommandBridgeLong] 长连接已在运行,App ID / App Secret / Token 变更需要重启 MoviePilot 后生效" ) return self._fingerprint = fingerprint self._thread = threading.Thread( target=self._run, name="feishu-command-bridge-long", daemon=True, ) self._thread.start() def _run(self) -> None: plugin = self._plugin if plugin is None: return def _on_message(data) -> None: current_plugin = self._plugin if current_plugin is None: return current_plugin._handle_long_connection_event(data) try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) import lark_oapi.ws.client as lark_ws_client lark_ws_client.loop = loop event_handler = ( lark.EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(_on_message) .build() ) ws_client = lark.ws.Client( plugin._app_id, plugin._app_secret, log_level=lark.LogLevel.DEBUG if plugin._debug else lark.LogLevel.INFO, event_handler=event_handler, ) logger.info("[FeishuCommandBridgeLong] 正在启动飞书长连接") ws_client.start() except Exception as exc: logger.error(f"[FeishuCommandBridgeLong] 长连接退出:{exc}\n{traceback.format_exc()}") def is_running(self) -> bool: with self._lock: return bool(self._thread and self._thread.is_alive()) _runtime = _LongConnectionRuntime() _EVENT_CACHE_FILE = Path("/config/plugins/FeishuCommandBridgeLong/.event_cache.json") _SMART_CACHE_FILE = Path("/config/plugins/FeishuCommandBridgeLong/.smart_cache.json") class FeishuCommandBridgeLong(_PluginBase): plugin_name = "飞书命令桥接" plugin_desc = "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。" plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/feishucommandbridgelong.png" plugin_version = "0.5.26" plugin_author = "liuyuexi1987" plugin_level = 1 author_url = "https://github.com/liuyuexi1987" plugin_config_prefix = "feishucommandbridgelong_" plugin_order = 29 auth_level = 1 _enabled = False _allow_all = False _verification_token = "" _app_id = "" _app_secret = "" _allowed_chat_ids: List[str] = [] _allowed_user_ids: List[str] = [] _reply_enabled = True _reply_receive_id_type = "chat_id" _command_whitelist: List[str] = [] _command_aliases = "" _debug = False _tmdb_api_key_override = "" _execution_backend = "legacy" _token_cache: Dict[str, Any] = {} _token_lock = threading.Lock() _event_cache: Dict[str, float] = {} _event_lock = threading.Lock() _search_cache: Dict[str, Dict[str, Any]] = {} _search_cache_lock = threading.Lock() _smart_cache: Dict[str, Dict[str, Any]] = {} _smart_cache_lock = threading.Lock() _candidate_actor_cache: Dict[str, List[str]] = {} _candidate_actor_cache_lock = threading.Lock() _tmdb_api_key_cache = "" _tmdb_api_key_lock = threading.Lock() @classmethod def _default_command_whitelist(cls) -> List[str]: return [ "/p115_manual_transfer", "/p115_inc_sync", "/p115_full_sync", "/p115_strm", "/quark_save", "/pansou_search", "/smart_entry", "/smart_pick", "/media_search", "/media_download", "/media_subscribe", "/media_subscribe_search", "/version", ] @classmethod def _default_command_aliases(cls) -> str: return ( "刮削=/p115_manual_transfer\n" "搜索=/media_search\n" "MP搜索=/media_search\n" "原生搜索=/media_search\n" "盘搜搜索=/pansou_search\n" "盘搜=/pansou_search\n" "ps=/pansou_search\n" "1=/pansou_search\n" "影巢搜索=/smart_entry\n" "yc=/smart_entry\n" "2=/smart_entry\n" "下载=/media_download\n" "订阅=/media_subscribe\n" "订阅搜索=/media_subscribe_search\n" "生成STRM=/p115_inc_sync\n" "全量STRM=/p115_full_sync\n" "指定路径STRM=/p115_strm\n" "夸克转存=/quark_save\n" "夸克=/quark_save\n" "链接=/smart_entry\n" "处理=/smart_entry\n" "115登录=/smart_entry\n" "115扫码=/smart_entry\n" "检查115登录=/smart_entry\n" "115登录状态=/smart_entry\n" "115状态=/smart_entry\n" "115帮助=/smart_entry\n" "115任务=/smart_entry\n" "继续115任务=/smart_entry\n" "取消115任务=/smart_entry\n" "选择=/smart_pick\n" "详情=/smart_pick\n" "审查=/smart_pick\n" "选=/smart_pick\n" "继续=/smart_pick\n" "影巢=/smart_entry\n" "搜索资源=/media_search\n" "下载资源=/media_download\n" "订阅媒体=/media_subscribe\n" "订阅并搜索=/media_subscribe_search\n" "版本=/version" ) @staticmethod def _clean_input(value: Any) -> str: if value is None: return "" text = str(value) for ch in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060", "\ufffc"): text = text.replace(ch, "") return text.strip() @classmethod def _normalize_execution_backend(cls, value: Any) -> str: clean = cls._clean_input(value).lower() if clean in {"auto", "agent_resource_officer", "legacy"}: return clean if clean in {"agent", "aro", "agentresourceofficer"}: return "agent_resource_officer" return "legacy" @classmethod def _describe_execution_backend(cls, value: Any) -> str: backend = cls._normalize_execution_backend(value) mapping = { "legacy": "旧桥接直连", "auto": "自动优先新主线", "agent_resource_officer": "仅走 Agent影视助手", } return mapping.get(backend, "旧桥接直连") def init_plugin(self, config: dict = None): config = config or {} self._enabled = bool(config.get("enabled")) self._allow_all = bool(config.get("allow_all")) self._verification_token = self._clean_input(config.get("verification_token")) self._app_id = self._clean_input(config.get("app_id")) self._app_secret = self._clean_input(config.get("app_secret")) self._allowed_chat_ids = self._split_lines(config.get("allowed_chat_ids")) self._allowed_user_ids = self._split_lines(config.get("allowed_user_ids")) self._reply_enabled = bool(config.get("reply_enabled", True)) self._reply_receive_id_type = str( config.get("reply_receive_id_type") or "chat_id" ).strip() self._command_whitelist = self._merge_command_whitelist( self._split_commands(config.get("command_whitelist")) ) self._command_aliases = self._merge_command_aliases( str(config.get("command_aliases") or "").strip() ) self._debug = bool(config.get("debug")) self._tmdb_api_key_override = self._clean_input(config.get("tmdb_api_key")) self._execution_backend = self._normalize_execution_backend( config.get("execution_backend") ) type(self)._tmdb_api_key_override = self._tmdb_api_key_override with type(self)._tmdb_api_key_lock: type(self)._tmdb_api_key_cache = "" _runtime.start(self) def get_state(self) -> bool: return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: return [] def get_api(self) -> List[Dict[str, Any]]: return [ { "path": "/health", "endpoint": self.health, "methods": ["GET"], "summary": "健康检查", "description": "返回飞书长连接插件当前状态与基础配置", "auth": "bear", }, { "path": "/assistant/route", "endpoint": self.api_assistant_route, "methods": ["POST"], "summary": "智能单入口分流", "description": "自动识别夸克链接、115 链接或影巢片名搜索", "auth": "bear", }, { "path": "/assistant/pick", "endpoint": self.api_assistant_pick, "methods": ["POST"], "summary": "按编号继续执行", "description": "对上一轮智能分流结果按编号确认执行", "auth": "bear", }, ] def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { "component": "VForm", "content": [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "enabled", "label": "启用插件", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "allow_all", "label": "允许所有飞书会话", }, }, ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "verification_token", "label": "Verification Token", "placeholder": "飞书事件订阅 Token", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "tmdb_api_key", "label": "TMDB API Key(可选)", "placeholder": "仅用于影巢候选影片补充主演", "type": "password", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "app_id", "label": "App ID", "placeholder": "cli_xxxxxxxxx", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "app_secret", "label": "App Secret", "placeholder": "飞书应用凭证", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "allowed_chat_ids", "label": "允许的群聊 Chat ID", "rows": 4, "placeholder": "一个一行;留空时仅允许 allow_all 或允许的用户", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "allowed_user_ids", "label": "允许的用户 Open ID", "rows": 4, "placeholder": "一个一行", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "command_whitelist", "label": "命令白名单", "placeholder": ",".join(self._default_command_whitelist()), }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "reply_enabled", "label": "发送即时回执", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "command_aliases", "label": "命令别名", "rows": 6, "placeholder": self._default_command_aliases(), }, } ], } ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VSelect", "props": { "model": "execution_backend", "label": "执行后端", "items": [ {"title": "旧桥接直连(推荐保留旧体验)", "value": "legacy"}, {"title": "自动优先新主线,失败回落旧桥接", "value": "auto"}, {"title": "仅走 Agent影视助手 新主线", "value": "agent_resource_officer"}, ], }, }, ], } ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VSwitch", "props": { "model": "debug", "label": "输出调试日志", }, } ], } ], }, ], } ], { "enabled": self._enabled, "allow_all": self._allow_all, "verification_token": self._verification_token, "app_id": self._app_id, "app_secret": self._app_secret, "allowed_chat_ids": "\n".join(self._allowed_chat_ids), "allowed_user_ids": "\n".join(self._allowed_user_ids), "reply_enabled": self._reply_enabled, "reply_receive_id_type": self._reply_receive_id_type, "command_whitelist": ",".join(self._command_whitelist) if self._command_whitelist else ",".join(self._default_command_whitelist()), "command_aliases": self._command_aliases or self._default_command_aliases(), "debug": self._debug, "tmdb_api_key": self._tmdb_api_key_override, "execution_backend": self._execution_backend or "legacy", } def get_page(self) -> Optional[List[dict]]: aliases = self._parse_aliases() alias_lines = [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"{key} -> {value}", } for key, value in aliases.items() ] or [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "未配置别名", } ] command_lines = [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": cmd, } for cmd in (self._command_whitelist or []) ] or [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "未配置命令白名单", } ] return [ { "component": "VContainer", "content": [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VCard", "props": {"border": True, "flat": True}, "content": [ { "component": "VCardTitle", "text": "运行状态", }, { "component": "VCardText", "content": [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"启用状态:{'是' if self._enabled else '否'}", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"长连接运行中:{'是' if _runtime.is_running() else '否'}", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"执行后端:{self._describe_execution_backend(self._execution_backend)}", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"允许所有会话:{'是' if self._allow_all else '否'}", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"App ID:{self._app_id or '未填写'}", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": f"Token:{self._mask_secret(self._verification_token) or '未填写'}", }, ], }, ], } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VCard", "props": {"border": True, "flat": True}, "content": [ { "component": "VCardTitle", "text": "可用命令", }, { "component": "VCardText", "content": command_lines, }, ], } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VCard", "props": {"border": True, "flat": True}, "content": [ { "component": "VCardTitle", "text": "命令别名", }, { "component": "VCardText", "content": alias_lines, }, ], } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VCard", "props": {"border": True, "flat": True}, "content": [ { "component": "VCardTitle", "text": "使用示例", }, { "component": "VCardText", "content": [ { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "处理 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "选择 1", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "版本", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "刮削 /待整理/", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "/p115_strm /待整理/", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "MP搜索 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "影巢搜索 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "盘搜搜索 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "115登录", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "115帮助", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "检查115登录", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "115任务", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "继续115任务", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "取消115任务", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "链接 https://115cdn.com/s/xxxx path=/待整理", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "下载资源 1", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "订阅媒体 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "订阅并搜索 流浪地球2", }, { "component": "div", "props": {"class": "text-body-2 py-1"}, "text": "帮助", }, ], }, ], } ], }, ], }, ], } ] def health(self): return { "plugin_version": self.plugin_version, "enabled": self._enabled, "running": _runtime.is_running(), "allow_all": self._allow_all, "reply_enabled": self._reply_enabled, "allowed_chat_count": len(self._allowed_chat_ids), "allowed_user_count": len(self._allowed_user_ids), "command_whitelist": self._command_whitelist, "sdk_available": lark is not None, } async def api_assistant_route(self, request: Request) -> Dict[str, Any]: try: body = await request.json() except Exception: body = {} session = self._clean_input( body.get("session") or body.get("chat_id") or body.get("user_id") or body.get("conversation_id") or "default" ) text = self._clean_input( body.get("text") or body.get("query") or body.get("message") or "" ) mode, query = self._strip_search_prefix(text) cache_key = f"api::{session}" if mode == "mp": message = await asyncio.to_thread(self._execute_media_search, query, cache_key) ok = "失败" not in message and "未识别" not in message data = {"action": "media_search", "ok": ok, "keyword": query} elif mode == "pansou": message = await asyncio.to_thread(self._execute_pansou_search, query, cache_key) ok = not message.startswith("盘搜搜索失败") data = {"action": "pansou_search", "ok": ok, "keyword": query} elif mode == "hdhive": ok, message, data = await asyncio.to_thread( self._execute_smart_entry, query, cache_key, ) else: ok, message, data = await asyncio.to_thread( self._execute_smart_entry, text, cache_key, ) return {"success": ok, "message": message, "data": data} async def api_assistant_pick(self, request: Request) -> Dict[str, Any]: try: body = await request.json() except Exception: body = {} session = self._clean_input( body.get("session") or body.get("chat_id") or body.get("user_id") or body.get("conversation_id") or "default" ) if body.get("arg"): arg = self._clean_input(body.get("arg")) else: index = str(body.get("index") or "").strip() path = self._normalize_pan_path(body.get("path") or "") arg = index if path: arg = f"{arg} path={path}".strip() ok, message, data = await asyncio.to_thread( self._execute_smart_pick, arg, f"api::{session}", ) return {"success": ok, "message": message, "data": data} def stop_service(self): logger.info("[FeishuCommandBridge] 当前版本未实现长连接主动停止;如需彻底停掉,请重启 MoviePilot") def _connection_fingerprint(self) -> str: return "|".join([ self._app_id, self._app_secret, self._verification_token, ]) def _handle_long_connection_event(self, data) -> None: if not self._enabled: return event_context = data event = getattr(event_context, "event", None) header = getattr(event_context, "header", None) message = getattr(event, "message", None) sender = getattr(event, "sender", None) sender_id = getattr(sender, "sender_id", None) event_id = str(getattr(header, "event_id", "") or "").strip() if event_id and self._is_duplicate_event(event_id): return if self._debug: logger.info( f"[FeishuCommandBridge] event_id={event_id} " f"event_type={getattr(header, 'event_type', '')} " f"chat_id={getattr(message, 'chat_id', '')}" ) if not message or str(getattr(message, "message_type", "")).strip() != "text": return raw_text = self._extract_text(getattr(message, "content", None)) if not raw_text: return sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip() chat_id = str(getattr(message, "chat_id", "") or "").strip() if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): self._reply_if_needed( receive_chat_id=chat_id, receive_open_id=sender_open_id, text="该会话未在白名单中,命令已拒绝。", ) return if self._is_help_request(raw_text): self._reply_if_needed( receive_chat_id=chat_id, receive_open_id=sender_open_id, text=self._build_help_text(), ) return if self._is_menu_request(raw_text): self._reply_if_needed( receive_chat_id=chat_id, receive_open_id=sender_open_id, text=self._build_menu_text(), ) return command_text = self._map_text_to_command(raw_text) if not command_text: return cmd = command_text.split()[0] if cmd not in self._command_whitelist: self._reply_if_needed( receive_chat_id=chat_id, receive_open_id=sender_open_id, text=f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}", ) return if self._handle_builtin_command( command_text=command_text, receive_chat_id=chat_id, receive_open_id=sender_open_id, ): return logger.info(f"[FeishuCommandBridge] 转发命令:{command_text}") eventmanager.send_event( EventType.CommandExcute, { "cmd": command_text, "source": None, "user": sender_open_id or chat_id or "feishu", }, ) self._reply_if_needed( receive_chat_id=chat_id, receive_open_id=sender_open_id, text=f"已接收命令:{command_text}\n任务已提交给 MoviePilot。", ) def _handle_builtin_command( self, command_text: str, receive_chat_id: str, receive_open_id: str, ) -> bool: parts = command_text.split(maxsplit=1) cmd = parts[0].strip() arg = parts[1].strip() if len(parts) > 1 else "" if cmd == "/p115_strm" and not arg: command_text = "/p115_full_sync" logger.info(f"[FeishuCommandBridge] 转发命令:{command_text}") eventmanager.send_event( EventType.CommandExcute, { "cmd": command_text, "source": None, "user": receive_open_id or receive_chat_id or "feishu", }, ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"已接收命令:{command_text}\n任务已提交给 MoviePilot。", ) return True if cmd == "/media_search": if not arg: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text="用法:搜索资源 片名\n示例:MP搜索 流浪地球2", ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在使用 MP 原生搜索:{arg}\n我会返回前 10 条结果,之后可直接回复:下载资源 序号", ) threading.Thread( target=self._run_media_search, args=(arg, receive_chat_id, receive_open_id), name="feishu-media-search", daemon=True, ).start() return True if cmd == "/pansou_search": if not arg: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text="用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2", ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在使用盘搜搜索:{arg}", ) threading.Thread( target=self._run_pansou_search, args=(arg, receive_chat_id, receive_open_id), name="feishu-pansou-search", daemon=True, ).start() return True if cmd == "/media_download": if not arg or not arg.isdigit(): self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text="用法:下载资源 序号\n示例:下载资源 1", ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在提交第 {arg} 条资源到下载器,请稍候。", ) threading.Thread( target=self._run_media_download, args=(int(arg), receive_chat_id, receive_open_id), name="feishu-media-download", daemon=True, ).start() return True if cmd == "/quark_save": if not arg: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=( "用法:夸克转存 分享链接 pwd=提取码 path=/保存目录\n" "示例:夸克转存 https://pan.quark.cn/s/xxxx pwd=abcd path=/最新动画" ), ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在处理夸克转存:{arg}", ) threading.Thread( target=self._run_quark_save, args=(arg, receive_chat_id, receive_open_id), name="feishu-quark-save", daemon=True, ).start() return True if cmd == "/smart_entry": if not arg: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=( "用法:处理 片名 或 处理 分享链接\n" "示例1:处理 流浪地球2\n" "示例2:处理 https://pan.quark.cn/s/xxxx pwd=abcd path=/最新动画" ), ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在智能处理:{arg}", ) threading.Thread( target=self._run_smart_entry, args=(arg, receive_chat_id, receive_open_id), name="feishu-smart-entry", daemon=True, ).start() return True if cmd == "/smart_pick": if not arg: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=( "用法:选择 序号\n" "示例:选择 1\n" "也支持:直接回复 1\n" "也支持:选择 1 path=/目录\n" "如需补充当前候选页全部主演:详情" ), ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在继续执行:{arg}", ) threading.Thread( target=self._run_smart_pick, args=(arg, receive_chat_id, receive_open_id), name="feishu-smart-pick", daemon=True, ).start() return True if cmd in {"/media_subscribe", "/media_subscribe_search"}: if not arg: usage = ( "用法:订阅媒体 片名" if cmd == "/media_subscribe" else "用法:订阅并搜索 片名" ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"{usage}\n示例:{usage.replace('片名', '流浪地球2')}", ) return True immediate_search = cmd == "/media_subscribe_search" action_text = "订阅并搜索" if immediate_search else "订阅" self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"正在{action_text}:{arg}", ) threading.Thread( target=self._run_media_subscribe, args=(arg, immediate_search, receive_chat_id, receive_open_id), name="feishu-media-subscribe", daemon=True, ).start() return True if cmd != "/p115_manual_transfer": return False if not arg: paths = self._get_p115_manual_transfer_paths() if not paths: self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text="未配置待整理目录。\n请先在 P115StrmHelper 中配置 pan_transfer_paths,或直接发送:刮削 /待整理/", ) return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=( f"已开始刮削 {len(paths)} 个目录:\n" + "\n".join(f"- {path}" for path in paths) + "\n正在调用 115 整理流程,请稍候。" ), ) threading.Thread( target=self._run_p115_manual_transfer_batch, args=(paths, receive_chat_id, receive_open_id), name="feishu-p115-manual-transfer-batch", daemon=True, ).start() return True self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=f"已开始刮削:{arg}\n正在调用 115 整理流程,请稍候。", ) threading.Thread( target=self._run_p115_manual_transfer, args=(arg, receive_chat_id, receive_open_id), name="feishu-p115-manual-transfer", daemon=True, ).start() return True def _get_p115_manual_transfer_paths(self) -> List[str]: try: config = self.systemconfig.get("plugin.P115StrmHelper") or {} raw = str(config.get("pan_transfer_paths") or "").strip() if not raw: return [] return [line.strip() for line in raw.splitlines() if line.strip()] except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取待整理目录失败:{exc}") return [] def _run_p115_manual_transfer_batch( self, paths: List[str], receive_chat_id: str, receive_open_id: str, ) -> None: summaries: List[str] = [] for path in paths: summaries.append(self._execute_p115_manual_transfer(path)) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text="\n\n".join(summary for summary in summaries if summary), ) def _run_p115_manual_transfer( self, path: str, receive_chat_id: str, receive_open_id: str, ) -> None: summary_text = self._execute_p115_manual_transfer(path) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=summary_text, ) def _execute_p115_manual_transfer(self, path: str) -> str: log_path = Path("/config/logs/plugins/P115StrmHelper.log") log_offset = self._safe_log_offset(log_path) try: service_module = importlib.import_module( "app.plugins.p115strmhelper.service" ) servicer = getattr(service_module, "servicer", None) if not servicer or not getattr(servicer, "monitorlife", None): return "刮削失败:P115StrmHelper 未初始化或未启用。" logger.info(f"[FeishuCommandBridge] 开始执行手动刮削:{path}") result = servicer.monitorlife.once_transfer(path) logger.info(f"[FeishuCommandBridge] 手动刮削完成:{path}") summary_text = self._format_p115_manual_transfer_result(result) if not summary_text: summary_text = self._build_p115_manual_transfer_summary(log_path, log_offset, path) return summary_text or f"刮削完成:{path}" except Exception as exc: logger.error( f"[FeishuCommandBridge] 手动刮削失败:{path} {exc}\n{traceback.format_exc()}" ) return f"刮削失败:{path}\n错误:{exc}" def _format_p115_manual_transfer_result(self, result: Any) -> Optional[str]: if not isinstance(result, dict): return None path = result.get("path") or "" total = result.get("total", 0) files = result.get("files", 0) dirs = result.get("dirs", 0) success = result.get("success", 0) failed = result.get("failed", 0) skipped = result.get("skipped", 0) error = result.get("error") failed_items = result.get("failed_items") or [] lines = [ f"刮削完成:{path}", f"总计:{total} 个项目(文件 {files},文件夹 {dirs})", f"成功:{success} 个", f"失败:{failed} 个", f"跳过:{skipped} 个", ] if error: lines.append(f"错误:{error}") if failed_items: lines.append("失败示例:") lines.extend(f"- {item}" for item in failed_items[:3]) remain = len(failed_items) - 3 if remain > 0: lines.append(f"- 还有 {remain} 项未展示") strm_hint_path = self._get_p115_strm_hint_path() or path lines.append("如需增量生成 STRM,请再发送:生成STRM") lines.append("如需按全部媒体库全量生成,请再发送:全量STRM") lines.append(f"如需指定路径全量生成,请再发送:指定路径STRM {strm_hint_path}") return "\n".join(lines) def _get_p115_strm_hint_path(self) -> Optional[str]: try: config = self.systemconfig.get("plugin.P115StrmHelper") or {} paths = str(config.get("full_sync_strm_paths") or "").strip() if not paths: return None first_line = next( (line.strip() for line in paths.splitlines() if line.strip()), "", ) if not first_line: return None parts = first_line.split("#") if len(parts) >= 2 and parts[1].strip(): return parts[1].strip() except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取 P115 STRM 提示路径失败:{exc}") return None def _safe_log_offset(self, log_path: Path) -> int: try: if log_path.exists(): return log_path.stat().st_size except Exception: pass return 0 def _build_p115_manual_transfer_summary( self, log_path: Path, start_offset: int, path: str, ) -> Optional[str]: try: if not log_path.exists(): return None with log_path.open("r", encoding="utf-8", errors="ignore") as f: f.seek(start_offset) chunk = f.read() if not chunk: return None path_re = re.escape(path) summary_pattern = re.compile( rf"手动网盘整理完成 - 路径: {path_re}\n" rf"\s*总计: (?P\d+) 个项目 \(文件: (?P\d+), 文件夹: (?P\d+)\)\n" rf"\s*成功: (?P\d+) 个\n" rf"\s*失败: (?P\d+) 个\n" rf"\s*跳过: (?P\d+) 个", re.S, ) match = summary_pattern.search(chunk) if not match: return None summary = ( f"刮削完成:{path}\n" f"总计:{match.group('total')} 个项目" f"(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" f"成功:{match.group('success')} 个\n" f"失败:{match.group('failed')} 个\n" f"跳过:{match.group('skipped')} 个" ) failed_pattern = re.compile( r"失败项目详情 \((?P\d+) 个\):\n(?P(?:\s*-\s.*(?:\n|$))*)", re.S, ) failed_match = failed_pattern.search(chunk, match.end()) if failed_match: items = [ item.strip()[2:].strip() for item in failed_match.group("items").splitlines() if item.strip().startswith("- ") ] if items: preview = "\n".join(f"- {item}" for item in items[:3]) remain = len(items) - 3 summary += f"\n失败示例:\n{preview}" if remain > 0: summary += f"\n- 还有 {remain} 项未展示" strm_hint_path = self._get_p115_strm_hint_path() or path summary += "\n如需增量生成 STRM,请再发送:生成STRM" summary += "\n如需按全部媒体库全量生成,请再发送:全量STRM" summary += f"\n如需指定路径全量生成,请再发送:指定路径STRM {strm_hint_path}" return summary except Exception as exc: logger.warning(f"[FeishuCommandBridge] 解析 P115 刮削结果失败:{exc}") return None def _is_duplicate_event(self, event_id: str) -> bool: now = time.time() with self._event_lock: expired = [key for key, ts in self._event_cache.items() if now - ts > 600] for key in expired: self._event_cache.pop(key, None) if event_id in self._event_cache: return True self._event_cache[event_id] = now return self._is_duplicate_event_cross_instance(event_id, now) def _is_duplicate_event_cross_instance(self, event_id: str, now: float) -> bool: try: _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) with _EVENT_CACHE_FILE.open("a+", encoding="utf-8") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) f.seek(0) raw = f.read().strip() cache = json.loads(raw) if raw else {} cache = { key: ts for key, ts in cache.items() if isinstance(ts, (int, float)) and now - float(ts) <= 600 } if event_id in cache: f.seek(0) f.truncate() json.dump(cache, f, ensure_ascii=False) f.flush() fcntl.flock(f.fileno(), fcntl.LOCK_UN) return True cache[event_id] = now f.seek(0) f.truncate() json.dump(cache, f, ensure_ascii=False) f.flush() fcntl.flock(f.fileno(), fcntl.LOCK_UN) except Exception as exc: logger.warning(f"[FeishuCommandBridge] 跨实例事件去重失败:{exc}") return False def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: if self._allow_all: return True if chat_id and chat_id in self._allowed_chat_ids: return True if user_open_id and user_open_id in self._allowed_user_ids: return True return False def _map_text_to_command(self, text: str) -> Optional[str]: text = self._sanitize_text(text) if not text: return None if text.startswith("/"): return text normalized = text.strip().lower() if normalized in {"n", "next", "下一页", "下页"} or normalized.startswith("n "): return f"/smart_pick {text}".strip() shortcut_match = re.fullmatch(r"(\d+)(?:\s+(.+))?", text) if shortcut_match: rest = str(shortcut_match.group(2) or "").strip() if not rest or "=" in rest or rest.startswith("/"): return f"/smart_pick {text}".strip() first_url = self._extract_first_url(text) if first_url and self._detect_share_kind(first_url) in {"115", "quark"}: return f"/smart_entry {text}".strip() alias_map = self._parse_aliases() parts = text.split(maxsplit=1) alias = parts[0] rest = parts[1] if len(parts) > 1 else "" target = alias_map.get(alias) if not target: for alias_key in sorted(alias_map.keys(), key=len, reverse=True): if not text.startswith(alias_key): continue remain = text[len(alias_key):].strip() target = alias_map.get(alias_key) if target: if target == "/smart_pick" and alias_key in {"详情", "审查"}: return f"{target} {alias_key} {remain}".strip() return f"{target} {remain}".strip() return None if target == "/smart_pick" and alias in {"详情", "审查"}: return f"{target} {alias} {rest}".strip() return f"{target} {rest}".strip() def _is_help_request(self, text: str) -> bool: text = self._sanitize_text(text) return text in {"帮助", "/help", "help"} def _is_menu_request(self, text: str) -> bool: text = self._sanitize_text(text) return text in {"菜单", "/menu", "menu", "面板", "控制面板"} def _parse_aliases(self) -> Dict[str, str]: result: Dict[str, str] = {} for line in self._command_aliases.splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if key and value.startswith("/"): result[key] = value return result @classmethod def _merge_command_whitelist(cls, configured: List[str]) -> List[str]: merged: List[str] = [] seen = set() for cmd in configured or []: if cmd and cmd not in seen: merged.append(cmd) seen.add(cmd) for cmd in cls._default_command_whitelist(): if cmd not in seen: merged.append(cmd) seen.add(cmd) return merged @classmethod def _merge_command_aliases(cls, configured_text: str) -> str: merged = cls._parse_alias_text(cls._default_command_aliases()) for key, value in cls._parse_alias_text(configured_text).items(): merged[key] = value return "\n".join(f"{key}={value}" for key, value in merged.items()) @staticmethod def _parse_alias_text(text: str) -> Dict[str, str]: result: Dict[str, str] = {} for line in str(text or "").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if key and value.startswith("/"): result[key] = value return result def _build_help_text(self) -> str: aliases = self._parse_aliases() alias_lines = [f"{k} -> {v}" for k, v in aliases.items()] alias_text = "\n".join(alias_lines) if alias_lines else "未配置别名" return ( "可用命令:\n" f"{', '.join(self._command_whitelist)}\n\n" "别名:\n" f"{alias_text}\n\n" "快捷入口:发送“菜单”可查看可复制的快捷命令。" ) def _build_menu_text(self) -> str: return ( "快捷菜单\n" "1. MP搜索 片名\n\n" "2. 影巢搜索 片名\n\n" "3. 盘搜搜索 片名\n\n" "4. 直接发 115 / 夸克链接\n\n" "5. 选择 序号\n\n" "6. 刮削\n\n" "7. 生成STRM\n\n" "8. 全量STRM\n\n" "9. 夸克转存 分享链接 pwd=提取码 path=/保存目录\n\n" "10. 下载资源 序号\n\n" "11. 订阅媒体 片名\n\n" "12. 订阅并搜索 片名\n\n" "13. 版本" ) def _cache_key(self, receive_chat_id: str, receive_open_id: str) -> str: return f"{receive_chat_id or ''}::{receive_open_id or ''}" def _set_search_cache( self, cache_key: str, keyword: str, mediainfo: Any, results: List[Any], ) -> None: with self._search_cache_lock: self._search_cache[cache_key] = { "ts": time.time(), "keyword": keyword, "mediainfo": mediainfo, "results": results[:10], } def _get_search_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: with self._search_cache_lock: item = self._search_cache.get(cache_key) if not item: return None if time.time() - float(item.get("ts") or 0) > 1800: self._search_cache.pop(cache_key, None) return None return item def _set_smart_cache( self, cache_key: str, *, action: str, items: List[Dict[str, Any]], target_path: str = "", keyword: str = "", meta: Optional[Dict[str, Any]] = None, ) -> None: item_limit = 50 if action == "hdhive_candidates" else 20 payload = { "ts": time.time(), "action": action, "keyword": keyword, "target_path": target_path, "items": items[:item_limit], "meta": meta or {}, } with self._smart_cache_lock: self._smart_cache[cache_key] = payload self._persist_smart_cache(cache_key, payload) def _get_smart_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: with self._smart_cache_lock: item = self._smart_cache.get(cache_key) if not item: item = self._load_persisted_smart_cache(cache_key) if item: with self._smart_cache_lock: self._smart_cache[cache_key] = item if not item: return None if time.time() - float(item.get("ts") or 0) > 1800: with self._smart_cache_lock: self._smart_cache.pop(cache_key, None) self._remove_persisted_smart_cache(cache_key) return None return item def _persist_smart_cache(self, cache_key: str, payload: Dict[str, Any]) -> None: try: _SMART_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) with _SMART_CACHE_FILE.open("a+", encoding="utf-8") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) f.seek(0) raw = f.read().strip() cache = json.loads(raw) if raw else {} if not isinstance(cache, dict): cache = {} now = time.time() cache = { key: value for key, value in cache.items() if isinstance(value, dict) and now - float(value.get("ts") or 0) <= 1800 } cache[cache_key] = payload f.seek(0) f.truncate() json.dump(cache, f, ensure_ascii=False) f.flush() fcntl.flock(f.fileno(), fcntl.LOCK_UN) except Exception as exc: logger.warning(f"[FeishuCommandBridge] 写入智能缓存失败:{exc}") def _load_persisted_smart_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: try: if not _SMART_CACHE_FILE.exists(): return None with _SMART_CACHE_FILE.open("r", encoding="utf-8") as f: fcntl.flock(f.fileno(), fcntl.LOCK_SH) raw = f.read().strip() fcntl.flock(f.fileno(), fcntl.LOCK_UN) cache = json.loads(raw) if raw else {} item = cache.get(cache_key) if isinstance(cache, dict) else None return item if isinstance(item, dict) else None except Exception as exc: logger.warning(f"[FeishuCommandBridge] 读取智能缓存失败:{exc}") return None def _remove_persisted_smart_cache(self, cache_key: str) -> None: try: if not _SMART_CACHE_FILE.exists(): return with _SMART_CACHE_FILE.open("a+", encoding="utf-8") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) f.seek(0) raw = f.read().strip() cache = json.loads(raw) if raw else {} if isinstance(cache, dict) and cache.pop(cache_key, None) is not None: f.seek(0) f.truncate() json.dump(cache, f, ensure_ascii=False) f.flush() fcntl.flock(f.fileno(), fcntl.LOCK_UN) except Exception as exc: logger.warning(f"[FeishuCommandBridge] 删除智能缓存失败:{exc}") def _run_media_search( self, keyword: str, receive_chat_id: str, receive_open_id: str, ) -> None: text = self._execute_media_search( keyword=keyword, cache_key=self._cache_key(receive_chat_id, receive_open_id), ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) def _run_pansou_search( self, keyword: str, receive_chat_id: str, receive_open_id: str, ) -> None: text = self._execute_pansou_search( keyword=keyword, cache_key=self._cache_key(receive_chat_id, receive_open_id), ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) def _run_media_download( self, index: int, receive_chat_id: str, receive_open_id: str, ) -> None: text = self._execute_media_download( index=index, cache_key=self._cache_key(receive_chat_id, receive_open_id), ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) def _run_media_subscribe( self, keyword: str, immediate_search: bool, receive_chat_id: str, receive_open_id: str, ) -> None: text = self._execute_media_subscribe( keyword=keyword, immediate_search=immediate_search, ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) def _run_smart_entry( self, arg: str, receive_chat_id: str, receive_open_id: str, ) -> None: ok, text, data = self._execute_smart_entry( arg=arg, cache_key=self._cache_key(receive_chat_id, receive_open_id), ) result = data.get("result") or {} if data.get("action") == "p115_qrcode_start": self._reply_qrcode_data_url_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, data_url=str(result.get("qrcode") or ""), ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) def _run_smart_pick( self, arg: str, receive_chat_id: str, receive_open_id: str, ) -> None: ok, text, _ = self._execute_smart_pick( arg=arg, cache_key=self._cache_key(receive_chat_id, receive_open_id), ) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=text, ) @staticmethod def _extract_first_url(text: str) -> str: match = re.search(r"https?://[^\s<>\"']+", str(text or "")) return match.group(0).rstrip(".,);]") if match else "" @staticmethod def _is_p115_qrcode_start_text(text: str) -> bool: compact = re.sub(r"\s+", "", str(text or "")).lower() return compact in { "115登录", "115扫码", "扫码115", "登录115", "115login", "115qrcode", "p115login", "p115qrcode", } @staticmethod def _is_p115_qrcode_check_text(text: str) -> bool: compact = re.sub(r"\s+", "", str(text or "")).lower() return compact in { "检查115登录", "115登录状态", "115状态", "检查115扫码", "检查扫码", "115check", "check115login", "p115check", } @staticmethod def _is_p115_assistant_text(text: str) -> bool: compact = re.sub(r"\s+", "", str(text or "")).lower() return compact in { "115帮助", "115任务", "继续115任务", "取消115任务", } @classmethod def _is_forced_aro_smart_text(cls, text: str) -> bool: return cls._is_p115_qrcode_start_text(text) or cls._is_p115_qrcode_check_text(text) or cls._is_p115_assistant_text(text) @staticmethod def _detect_share_kind(url: str) -> str: host = (urlparse(url).hostname or "").lower().strip(".") if host.endswith("quark.cn"): return "quark" if host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host: return "115" return "" @staticmethod def _normalize_pan_path(path: str) -> str: text = str(path or "").strip() if not text: return "" if not text.startswith("/"): text = f"/{text}" return re.sub(r"/+", "/", text).rstrip("/") or "/" @classmethod def _resolve_pan_path_value(cls, value: str) -> str: text = str(value or "").strip() if not text: return "" alias_map = { "分享": "/飞书", "飞书": "/飞书", "待整理": "/待整理", "最新动画": "/最新动画", } mapped = alias_map.get(text, text) return cls._normalize_pan_path(mapped) @staticmethod def _normalize_search_text(text: str) -> str: value = str(text or "").strip().lower() value = re.sub(r"\s+", "", value) value = re.sub(r"[^\w\u4e00-\u9fff]+", "", value) return value @staticmethod def _format_pansou_datetime(value: Any) -> str: text = str(value or "").strip() if not text or text.startswith("0001-01-01"): return "" text = text.replace("T", " ").replace("Z", "") if len(text) >= 10: text = text[:10].replace("-", "/") return text.strip() @staticmethod def _format_pansou_source(value: Any) -> str: text = str(value or "").strip() if not text: return "" return text.split(":", 1)[-1] if ":" in text else text @staticmethod def _short_share_code(url: str) -> str: text = str(url or "").strip() if not text: return "" match = re.search(r"/s/([^/?#]+)", text) code = match.group(1) if match else text.rstrip("/").rsplit("/", 1)[-1] return code[:6] def _parse_smart_arg(self, arg: str) -> Dict[str, str]: text = self._sanitize_text(arg or "") share_url = self._extract_first_url(text) remain = text.replace(share_url, " ").strip() if share_url else text keyword_parts: List[str] = [] options: Dict[str, str] = { "url": share_url, "access_code": "", "path": "", "type": "", "year": "", } for token in remain.split(): item = token.strip() if not item: continue if "=" in item: key, value = item.split("=", 1) key = key.strip().lower() value = value.strip() if key in {"pwd", "passcode", "code", "提取码"} and value: options["access_code"] = value continue if key in {"path", "dir", "目录", "位置"} and value: options["path"] = self._resolve_pan_path_value(value) continue if key in {"type", "媒体类型"} and value: options["type"] = value.strip().lower() continue if key in {"year", "年份"} and value: options["year"] = value.strip() continue if item.startswith("/") and not options["path"]: options["path"] = self._resolve_pan_path_value(item) continue if not share_url and item in {"电影", "movie"}: options["type"] = "movie" continue if not share_url and item in {"电视剧", "剧集", "tv"}: options["type"] = "tv" continue if not share_url and not options["year"] and re.fullmatch(r"(19|20)\d{2}", item): options["year"] = item continue keyword_parts.append(item) keyword = " ".join(keyword_parts).strip() for prefix in ("影巢 ", "影巢搜索 ", "搜索影巢 "): if keyword.startswith(prefix): keyword = keyword[len(prefix):].strip() break media_type = options["type"] if media_type in {"电影", "movie"}: media_type = "movie" elif media_type in {"电视剧", "剧集", "tv"}: media_type = "tv" elif re.search(r"(第\s*\d+\s*季|S\d{1,2}|EP?\d+)", keyword, re.IGNORECASE): media_type = "tv" else: media_type = "movie" return { "url": options["url"], "access_code": options["access_code"], "path": options["path"], "type": media_type, "year": options["year"], "keyword": keyword, } @staticmethod def _parse_pick_arg(arg: str) -> Tuple[int, str, str]: text = str(arg or "").strip() index = 0 path = "" action = "pick" lowered = text.lower() if lowered in {"n", "next", "下一页", "下页"} or lowered.startswith("n "): action = "next_page" for token in text.split(): item = token.strip() if not item: continue if item.lower() in {"n", "next", "下一页", "下页"}: action = "next_page" continue if item.lower() in {"detail", "details", "review"} or item in {"详情", "审查"}: action = "detail" continue if item.isdigit() and index <= 0: index = int(item) continue if "=" in item: key, value = item.split("=", 1) if key.strip().lower() in {"path", "dir", "目录", "位置"} and value.strip(): path = value.strip() continue if item.startswith("/") and not path: path = item return index, FeishuCommandBridgeLong._resolve_pan_path_value(path), action @staticmethod def _strip_search_prefix(text: str) -> Tuple[str, str]: raw = str(text or "").strip() if FeishuCommandBridgeLong._is_forced_aro_smart_text(raw): return "", raw mappings = [ ("1搜索", "pansou"), ("2搜索", "hdhive"), ("MP搜索", "mp"), ("原生搜索", "mp"), ("搜索资源", "mp"), ("搜索", "mp"), ("影巢搜索", "hdhive"), ("yc", "hdhive"), ("2", "hdhive"), ("盘搜搜索", "pansou"), ("盘搜", "pansou"), ("ps", "pansou"), ("1", "pansou"), ] for prefix, mode in mappings: if raw == prefix: return mode, "" if raw.startswith(prefix + " "): return mode, raw[len(prefix):].strip() if raw.startswith(prefix): remain = raw[len(prefix):].strip() if remain: return mode, remain return "", raw def _get_hdhive_default_path(self) -> str: try: config = self.systemconfig.get("plugin.AgentResourceOfficer") or {} path = self._normalize_pan_path(config.get("hdhive_default_path") or "") if path: return path except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取 Agent影视助手影巢默认目录失败:{exc}") try: config = self.systemconfig.get("plugin.HdhiveOpenApi") or {} path = self._normalize_pan_path(config.get("transfer_115_path") or "") if path: return path except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取影巢默认目录失败:{exc}") return "/待整理" def _get_quark_default_path(self) -> str: try: config = self.systemconfig.get("plugin.AgentResourceOfficer") or {} path = self._normalize_pan_path(config.get("quark_default_path") or "") if path: return path except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取 Agent影视助手夸克默认目录失败:{exc}") try: config = self.systemconfig.get("plugin.QuarkShareSaver") or {} path = self._normalize_pan_path( config.get("default_target_path") or config.get("target_path") or "" ) if path: return path except Exception as exc: logger.warning(f"[FeishuCommandBridge] 获取夸克默认目录失败:{exc}") return "/飞书" def _local_api_base(self) -> str: return f"http://127.0.0.1:{settings.PORT}" @staticmethod def _get_running_plugin(plugin_id: str) -> Optional[Any]: try: return PluginManager().running_plugins.get(plugin_id) except Exception: return None def _should_use_agent_resource_officer(self) -> bool: backend = self._normalize_execution_backend(self._execution_backend) aro = self._get_running_plugin("AgentResourceOfficer") if backend == "legacy": return False if backend == "agent_resource_officer": return aro is not None return aro is not None def _requires_agent_resource_officer(self) -> bool: return self._normalize_execution_backend(self._execution_backend) == "agent_resource_officer" def _has_agent_resource_officer(self) -> bool: return self._get_running_plugin("AgentResourceOfficer") is not None def _call_local_json_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Tuple[bool, Dict[str, Any], str]: query = {"apikey": settings.API_TOKEN} for key, value in (params or {}).items(): if value is None or value == "": continue query[key] = value url = f"{self._local_api_base()}{path}?{urlencode(query)}" try: response = RequestUtils().get(url=url) if response is None: return False, {}, "未收到本机插件响应" if hasattr(response, "json"): data = response.json() elif isinstance(response, (bytes, bytearray)): data = json.loads(response.decode("utf-8", "ignore")) elif isinstance(response, str): data = json.loads(response) else: raw = getattr(response, "text", None) if callable(raw): raw = raw() elif raw is None and hasattr(response, "read"): raw = response.read() if isinstance(raw, (bytes, bytearray)): raw = raw.decode("utf-8", "ignore") data = json.loads(raw or "{}") except Exception as exc: return False, {}, f"请求失败:{exc}" return bool(data.get("success")), data, str(data.get("message") or "") def _call_local_json_post(self, path: str, payload: Dict[str, Any]) -> Tuple[bool, Dict[str, Any], str]: url = f"{self._local_api_base()}{path}?apikey={settings.API_TOKEN}" try: response = RequestUtils(content_type="application/json").post( url=url, json=payload, ) if response is None: return False, {}, "未收到本机插件响应" data = response.json() except Exception as exc: return False, {}, f"请求失败:{exc}" return bool(data.get("success")), data, str(data.get("message") or "") def _call_quark_transfer( self, share_url: str, access_code: str = "", target_path: str = "", ) -> Tuple[bool, Dict[str, Any], str]: if self._should_use_agent_resource_officer(): ok, data, message = self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/quark/transfer", { "url": share_url, "access_code": access_code, "path": target_path, }, ) result = data.get("data") or {} final_message = ( message or str(result.get("message") or "") or str(result.get("error") or "") or str(result.get("detail") or "") ) return ok, {"data": result}, final_message if self._requires_agent_resource_officer(): return False, {}, "Agent影视助手 未加载" plugin = self._get_running_plugin("QuarkShareSaver") if not plugin: return False, {}, "QuarkShareSaver 未加载" ok, result, message = plugin.transfer_share( share_text=share_url, access_code=access_code, target_path=target_path, remember=True, trigger="FeishuCommandBridgeLong 智能入口", ) result = result or {} final_message = ( message or str(result.get("message") or "") or str(result.get("error") or "") or str(result.get("detail") or "") ) return ok, {"data": result}, final_message def _call_hdhive_search( self, keyword: str, media_type: str, year: str = "", candidate_limit: int = 5, limit: int = 10, ) -> Tuple[bool, Dict[str, Any], str]: plugin = self._get_running_plugin("HdhiveOpenApi") if not plugin: return False, {}, "HdhiveOpenApi 未加载" ok, result, message = asyncio.run( plugin.search_resources_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=candidate_limit, result_limit=limit, remember=True, ) ) return ok, {"data": result}, message def _call_aro_hdhive_session_search( self, keyword: str, media_type: str, year: str = "", target_path: str = "", ) -> Tuple[bool, Dict[str, Any], str]: return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/session/hdhive/search", { "keyword": keyword, "type": media_type or "movie", "year": year, "path": target_path, }, ) def _call_aro_hdhive_session_pick( self, session_id: str, index: int, target_path: str = "", ) -> Tuple[bool, Dict[str, Any], str]: return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/session/hdhive/pick", { "session_id": session_id, "index": index, "path": target_path, }, ) def _call_aro_assistant_route( self, session_id: str, text: str, ) -> Tuple[bool, Dict[str, Any], str]: return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/assistant/route", { "session": session_id, "text": text, }, ) def _call_aro_assistant_pick( self, session_id: str, index: int, target_path: str = "", action: str = "", ) -> Tuple[bool, Dict[str, Any], str]: return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/assistant/pick", { "session": session_id, "index": index, "path": target_path, "action": action, }, ) def _should_force_aro_for_p115_login(self, text: str) -> bool: return self._is_forced_aro_smart_text(text) def _call_hdhive_search_by_tmdb( self, tmdb_id: Any, media_type: str, year: str = "", limit: int = 20, ) -> Tuple[bool, Dict[str, Any], str]: tmdb_value = str(tmdb_id or "").strip() if not tmdb_value: return False, {}, "缺少 TMDB ID" if self._should_use_agent_resource_officer(): return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/hdhive/search", { "type": media_type or "movie", "tmdb_id": tmdb_value, "year": year, "limit": limit, }, ) if self._requires_agent_resource_officer(): return False, {}, "Agent影视助手 未加载" return self._call_local_json_get( "/api/v1/plugin/HdhiveOpenApi/resources/search", params={ "type": media_type or "movie", "tmdb_id": tmdb_value, "year": year, "limit": limit, }, ) @classmethod def _read_tmdb_api_key(cls) -> str: with cls._tmdb_api_key_lock: if cls._tmdb_api_key_cache: return cls._tmdb_api_key_cache override_key = cls._clean_input(getattr(cls, "_tmdb_api_key_override", "")) if override_key: cls._tmdb_api_key_cache = override_key return override_key env_key = cls._clean_input(__import__("os").environ.get("TMDB_API_KEY")) if env_key: cls._tmdb_api_key_cache = env_key return env_key compose_path = Path("/Applications/Dockge/moviepilot-ai-recognizer-gateway/docker-compose.yml") if compose_path.exists(): for line in compose_path.read_text(encoding="utf-8", errors="ignore").splitlines(): if "TMDB_API_KEY" not in line: continue _, _, value = line.partition(":") key = cls._clean_input(value.strip().strip("'\"")) if key: cls._tmdb_api_key_cache = key return key return "" @classmethod def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: clean_tmdb_id = cls._clean_input(tmdb_id) clean_media_type = cls._clean_input(media_type).lower() if not clean_tmdb_id or clean_media_type not in {"movie", "tv"}: return [] cache_key = f"{clean_media_type}:{clean_tmdb_id}" with cls._candidate_actor_cache_lock: cached = cls._candidate_actor_cache.get(cache_key) if cached is not None: return list(cached) tmdb_api_key = cls._read_tmdb_api_key() if not tmdb_api_key: return [] query = urlencode( { "api_key": tmdb_api_key, "language": "zh-CN", "append_to_response": "credits", } ) endpoint = "movie" if clean_media_type == "movie" else "tv" url = f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?{query}" actors: List[str] = [] try: request = UrlRequest(url=url, headers={"Accept": "application/json"}) with urlopen(request, timeout=20) as response: payload = json.loads(response.read().decode("utf-8", "ignore")) cast = ((payload.get("credits") or {}).get("cast") or []) if isinstance(payload, dict) else [] for member in cast[:10]: name = cls._clean_input((member or {}).get("name")) department = cls._clean_input((member or {}).get("known_for_department")) if not name: continue if department and department != "Acting": continue if name not in actors: actors.append(name) if len(actors) >= 2: break except Exception: actors = [] with cls._candidate_actor_cache_lock: cls._candidate_actor_cache[cache_key] = list(actors) return actors def _maybe_enrich_hdhive_candidate_with_actors( self, candidate: Dict[str, Any], *, enabled: bool = False, ) -> Dict[str, Any]: enriched = dict(candidate or {}) if not enabled: return enriched actors = enriched.get("actors") or [] if actors: return enriched enriched["actors"] = self._fetch_candidate_actors( enriched.get("tmdb_id"), str(enriched.get("media_type") or enriched.get("type") or ""), ) return enriched def _enrich_hdhive_candidates_with_actors( self, candidates: List[Dict[str, Any]], *, enabled: bool = False, ) -> List[Dict[str, Any]]: if not enabled: return [dict(item) for item in candidates] indexed_candidates = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] pending = [ (idx, candidate) for idx, candidate in indexed_candidates if not (candidate.get("actors") or []) ] enriched_map: Dict[int, Dict[str, Any]] = {idx: candidate for idx, candidate in indexed_candidates} if pending: max_workers = min(4, len(pending)) with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit( self._maybe_enrich_hdhive_candidate_with_actors, candidate, enabled=True, ): idx for idx, candidate in pending } for future in concurrent.futures.as_completed(future_map): idx = future_map[future] try: enriched_map[idx] = future.result() except Exception: enriched_map[idx] = dict(indexed_candidates[idx][1]) return [enriched_map[idx] for idx, _ in indexed_candidates] def _call_hdhive_unlock( self, slug: str, *, transfer_115: bool = True, target_path: str = "", ) -> Tuple[bool, Dict[str, Any], str]: if self._should_use_agent_resource_officer(): return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/hdhive/unlock", { "slug": slug, "path": target_path, "transfer_115": transfer_115, }, ) if self._requires_agent_resource_officer(): return False, {}, "Agent影视助手 未加载" plugin = self._get_running_plugin("HdhiveOpenApi") if not plugin: return False, {}, "HdhiveOpenApi 未加载" ok, result, message = plugin.unlock_resource( slug=slug, remember=True, transfer_115=transfer_115, transfer_path=target_path, ) return ok, {"data": result}, message def _call_hdhive_transfer_115( self, share_url: str, access_code: str = "", target_path: str = "", ) -> Tuple[bool, Dict[str, Any], str]: if self._should_use_agent_resource_officer(): return self._call_local_json_post( "/api/v1/plugin/AgentResourceOfficer/p115/transfer", { "url": share_url, "access_code": access_code, "path": target_path, }, ) if self._requires_agent_resource_officer(): return False, {}, "Agent影视助手 未加载" plugin = self._get_running_plugin("HdhiveOpenApi") if not plugin: return False, {}, "HdhiveOpenApi 未加载" ok, result, message = plugin.transfer_115_share( url=share_url, access_code=access_code, path=target_path, remember=True, trigger="FeishuCommandBridgeLong 智能入口", ) return ok, {"data": result}, message def _call_pansou_search(self, keyword: str) -> Tuple[bool, Dict[str, Any], str]: last_error = "" queries = [ {"kw": keyword, "res": "merge", "src": "all"}, {"kw": keyword}, {"keyword": keyword}, ] urls = [] for query in queries: urls.append(f"http://host.docker.internal:805/api/search?{urlencode(query)}") urls.append(f"http://127.0.0.1:805/api/search?{urlencode(query)}") data: Dict[str, Any] = {} for url in urls: try: request = UrlRequest(url=url, headers={"Accept": "application/json"}) with urlopen(request, timeout=20) as response: data = json.loads(response.read().decode("utf-8", "ignore")) break except Exception as exc: last_error = str(exc) data = {} if not data: return False, {}, f"盘搜请求失败:{last_error or '未知错误'}" ok = str(data.get("code")) == "0" if not ok: return False, data, str(data.get("message") or "盘搜搜索失败") return True, data, str(data.get("message") or "success") @staticmethod def _safe_points_text(item: Dict[str, Any]) -> str: value = item.get("unlock_points") if value is None or str(value).strip() == "": return "未知" return str(value) @staticmethod def _format_hdhive_candidate_label(candidate: Dict[str, Any]) -> str: title = str(candidate.get("title") or "未知影片").strip() year = str(candidate.get("year") or "").strip() media_type = str(candidate.get("media_type") or candidate.get("type") or "").strip() actors = candidate.get("actors") or [] parts = [] if year: parts.append(year) if media_type: parts.append(media_type) if actors: actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) if actor_text: parts.append(f"主演:{actor_text}") if parts: return f"{title} ({' | '.join(parts)})" return title @staticmethod def _format_hdhive_size(size: Any) -> str: text = str(size or "").strip() if not text or text.lower() == "none": return "" if re.search(r"[a-zA-Z]$", text): return text return f"{text}GB" @staticmethod def _normalize_hdhive_pan_type(value: Any) -> str: text = str(value or "").strip().lower() if "115" in text: return "115" if "quark" in text: return "quark" return text or "未知" def _collect_hdhive_channel_items( self, items: List[Dict[str, Any]], channel_name: str, limit: int, ) -> List[Dict[str, Any]]: channel_results: List[Dict[str, Any]] = [] seen = set() for item in items: if not isinstance(item, dict): continue pan_type = self._normalize_hdhive_pan_type(item.get("pan_type")) if pan_type != channel_name: continue slug = str(item.get("slug") or "").strip() title = str(item.get("title") or item.get("matched_title") or "未知资源").strip() remark = str(item.get("remark") or "").strip() key = slug or f"{title}|{remark}" if key in seen: continue seen.add(key) channel_results.append(item) if len(channel_results) >= limit: break return channel_results def _format_hdhive_candidate_text( self, keyword: str, candidates: List[Dict[str, Any]], target_path: str, page: int = 1, page_size: int = 10, ) -> str: total = len(candidates) safe_page_size = max(1, page_size) total_pages = max(1, (total + safe_page_size - 1) // safe_page_size) safe_page = min(max(1, page), total_pages) start = (safe_page - 1) * safe_page_size page_items = candidates[start:start + safe_page_size] lines = [ f"影巢搜索:{keyword}", f"候选影片:{total} 个,请先选择影片:", ] if total_pages > 1: lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") for candidate in page_items: idx = int(candidate.get("index") or 0) lines.append(f"{idx}. {self._format_hdhive_candidate_label(candidate)}") lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(lines) def _format_hdhive_search_text( self, keyword: str, items: List[Dict[str, Any]], selected_candidate: Optional[Dict[str, Any]], target_path: str, ) -> str: channel_115 = self._collect_hdhive_channel_items(items, "115", 6) channel_quark = self._collect_hdhive_channel_items(items, "quark", 6) fallback_items = [] if not channel_115 and not channel_quark: fallback_items = [item for item in items[:12] if isinstance(item, dict)] display_items: List[Dict[str, Any]] = [] for item in channel_115: display_items.append({**item, "index": len(display_items) + 1, "_channel": "115"}) for item in channel_quark: display_items.append({**item, "index": len(display_items) + 1, "_channel": "quark"}) for item in fallback_items: display_items.append( { **item, "index": len(display_items) + 1, "_channel": self._normalize_hdhive_pan_type(item.get("pan_type")), } ) lines = [f"影巢搜索:{keyword}"] if selected_candidate: lines.append(f"已选影片:{self._format_hdhive_candidate_label(selected_candidate)}") if channel_115 or channel_quark: lines.append( f"资源结果:共 {len(items)} 条,当前展示 115 {len(channel_115)} 条、夸克 {len(channel_quark)} 条:" ) else: lines.append(f"资源结果:共 {len(items)} 条,当前展示前 {len(display_items)} 条:") for cached in display_items: idx = cached["index"] channel = cached["_channel"] if idx == 1 and channel == "115": lines.append("🟦 115 结果") elif channel == "quark" and idx == len(channel_115) + 1: lines.append("🟨 夸克结果") title = str(cached.get("remark") or cached.get("title") or cached.get("matched_title") or "未知资源").strip() points = self._safe_points_text(cached) if points == "0": points_label = "免费" elif points == "未知": points_label = "积分未知" else: points_label = f"{points}分" lines.append(f"{idx}. [{channel}][{points_label}] {title}") detail_parts = [] matched_title = str(cached.get("matched_title") or "").strip() matched_year = str(cached.get("matched_year") or "").strip() if matched_title: match_label = f"{matched_title} ({matched_year})" if matched_year else matched_title detail_parts.append(f"匹配:{match_label}") resolutions = [str(v).strip() for v in (cached.get("video_resolution") or []) if str(v).strip()] if resolutions: detail_parts.append("/".join(resolutions[:2])) sources = [str(v).strip() for v in (cached.get("source") or []) if str(v).strip()] if sources: detail_parts.append("/".join(sources[:2])) size_text = self._format_hdhive_size(cached.get("share_size")) if size_text: detail_parts.append(size_text) if detail_parts: lines.append(f" {' | '.join(detail_parts)}") if not display_items: lines.append("当前没有可展示的资源结果。") lines.append(f"下一步:回复“选择 1”即可解锁并转存到 {target_path}。") if channel_quark: start_index = len(channel_115) + 1 lines.append(f"夸克结果从 {start_index} 开始编号;例如“选择 {start_index}”可直接处理第 1 条夸克结果。") lines.append(f"如需改目录,可发“选择 1 path=/目录”或“选择 {start_index} path=/目录”。") else: lines.append("如需改目录,可发“选择 1 path=/目录”。") return "\n".join(lines) def _format_smart_pick_text( self, selected: Dict[str, Any], response_data: Dict[str, Any], target_path: str, ) -> str: result = response_data.get("data") or {} unlock_data = result.get("data") or {} transfer_data = result.get("transfer_115") or {} quark_transfer = result.get("transfer_quark") or {} lines = [ "影巢已执行解锁", f"资源:{selected.get('title') or selected.get('matched_title') or '-'}", f"积分:{self._safe_points_text(selected)}", f"网盘:{selected.get('pan_type') or '-'}", ] if unlock_data.get("url") or unlock_data.get("full_url"): lines.append("解锁结果:已返回资源链接") success_lines: List[str] = [] failure_lines: List[str] = [] if transfer_data: transfer_ok = bool(transfer_data.get("ok")) if transfer_ok: success_lines.extend( [ "115转存:成功", f"目录:{transfer_data.get('path') or target_path}", ] ) if transfer_data.get("message") and str(transfer_data.get("message")).strip().lower() != "success": success_lines.append(f"详情:{transfer_data.get('message')}") elif transfer_data.get("message"): failure_lines.append(f"115转存失败:{transfer_data.get('message')}") else: transfer_msg = str(result.get("transfer_115_message") or "").strip() if transfer_msg: failure_lines.append(f"115转存失败:{transfer_msg}") if quark_transfer: quark_ok = bool(quark_transfer.get("ok")) if quark_ok: success_lines.extend( [ "夸克转存:成功", f"目录:{quark_transfer.get('target_path') or target_path or '-'}", ] ) if quark_transfer.get("message") and str(quark_transfer.get("message")).strip().lower() != "success": success_lines.append(f"详情:{quark_transfer.get('message')}") elif quark_transfer.get("message"): failure_lines.append(f"夸克转存失败:{quark_transfer.get('message')}") if success_lines: lines.extend(success_lines) elif failure_lines: lines.append("自动转存:未成功") lines.extend(failure_lines) return "\n".join(lines) def _format_aro_route_text( self, selected: Dict[str, Any], route_result: Dict[str, Any], target_path: str, ) -> str: unlock = route_result.get("unlock") or {} unlock_data = unlock.get("data") or {} route = route_result.get("route") or {} lines = [ "影巢已执行解锁", f"资源:{selected.get('title') or selected.get('matched_title') or '-'}", f"积分:{self._safe_points_text(selected)}", f"网盘:{selected.get('pan_type') or route.get('provider') or route.get('pan_type') or '-'}", ] if unlock_data.get("url") or unlock_data.get("full_url"): lines.append("解锁结果:已返回资源链接") provider = str(route.get("provider") or route.get("pan_type") or "").strip().lower() message = str(route.get("message") or "").strip() final_path = str(route.get("target_path") or target_path or "").strip() if provider == "115": lines.append("115转存:成功") elif provider == "quark": lines.append("夸克转存:成功") else: lines.append("自动路由:已完成") if final_path: lines.append(f"目录:{final_path}") if message and message.lower() != "success": lines.append(f"详情:{message}") return "\n".join(lines) def _format_pansou_pick_text( self, selected: Dict[str, Any], share_kind: str, response_data: Dict[str, Any], target_path: str, ) -> str: result = response_data.get("data") or {} title = str(selected.get("note") or "未命名资源").strip() lines = [ "盘搜结果已执行转存", f"资源:{title}", f"类型:{share_kind}", ] if share_kind == "quark": lines.append(f"目录:{result.get('target_path') or target_path or '-'}") else: lines.append(f"目录:{result.get('path') or target_path}") lines.append(f"结果:{result.get('message') or 'success'}") return "\n".join(lines) @staticmethod def _format_115_error_text(message: str) -> str: text = str(message or "").strip() if not text: return "115 转存失败:未知错误" if text.startswith("115 转存失败") or text.startswith("影巢解锁成功,但 115 转存失败"): return text return f"115 转存失败:{text}" @staticmethod def _compact_115_result(result: Dict[str, Any]) -> Dict[str, Any]: compact = { "ok": bool(result.get("ok")), "path": result.get("path"), "message": result.get("message"), } media_info = ((result.get("data") or {}).get("media_info") or {}) if isinstance(media_info, dict): compact["media"] = { "title": media_info.get("title"), "year": media_info.get("year"), "type": media_info.get("type"), "category": media_info.get("category"), } return compact @staticmethod def _compact_unlock_result(result: Dict[str, Any]) -> Dict[str, Any]: unlock_data = result.get("data") or {} transfer_data = result.get("transfer_115") or {} quark_transfer = result.get("transfer_quark") or {} compact = { "ok": bool(result.get("ok")), "status_code": result.get("status_code"), "message": result.get("message"), "slug": result.get("slug"), "share_url": unlock_data.get("full_url") or unlock_data.get("url"), "access_code": unlock_data.get("access_code"), } if transfer_data: compact["transfer_115"] = { "ok": bool(transfer_data.get("ok")), "path": transfer_data.get("path"), "message": transfer_data.get("message"), } elif result.get("transfer_115_message"): compact["transfer_115"] = { "ok": False, "path": None, "message": result.get("transfer_115_message"), } if quark_transfer: compact["transfer_quark"] = { "ok": bool(quark_transfer.get("ok")), "target_path": quark_transfer.get("target_path"), "task_id": quark_transfer.get("task_id"), "saved_count": quark_transfer.get("saved_count"), "message": quark_transfer.get("message"), } return compact def _execute_smart_entry( self, arg: str, cache_key: str, ) -> Tuple[bool, str, Dict[str, Any]]: if self._should_force_aro_for_p115_login(arg): ok, payload, message = self._call_aro_assistant_route(cache_key, arg) data = payload.get("data") or {} text = str(message or "处理失败").strip() return ok, text, { "action": data.get("action") or "assistant_route", "ok": ok, "message": text, "result": data, } if self._should_use_agent_resource_officer(): ok, payload, message = self._call_aro_assistant_route(cache_key, arg) data = payload.get("data") or {} text = str(message or "处理失败").strip() return ok, text, { "action": data.get("action") or "assistant_route", "ok": ok, "message": text, "result": data, } parsed = self._parse_smart_arg(arg) share_url = parsed["url"] access_code = parsed["access_code"] target_path = parsed["path"] keyword = parsed["keyword"] media_type = parsed["type"] year = parsed["year"] # Keep 115 direct-link handling on the new ARO path so pending-task, # login-resume and cancellation all stay in the same session chain. if share_url and self._detect_share_kind(share_url) == "115" and self._has_agent_resource_officer(): ok, payload, message = self._call_aro_assistant_route(cache_key, arg) data = payload.get("data") or {} text = str(message or "处理失败").strip() return ok, text, { "action": data.get("action") or "assistant_route", "ok": ok, "message": text, "result": data, } if share_url: share_kind = self._detect_share_kind(share_url) if share_kind == "quark": final_path = target_path or self._get_quark_default_path() ok, payload, message = self._call_quark_transfer(share_url, access_code, final_path) result = payload.get("data") or {} text = ( "夸克转存已完成\n" f"目录:{result.get('target_path') or final_path or '-'}" if ok else f"夸克转存失败:{message or '未知错误'}" ) return ok, text, { "action": "quark_transfer", "ok": ok, "message": message or text, "result": { "target_path": result.get("target_path"), "task_id": result.get("task_id"), "saved_count": result.get("saved_count"), }, } if share_kind == "115": final_path = target_path or self._get_hdhive_default_path() ok, payload, message = self._call_hdhive_transfer_115(share_url, access_code, final_path) result = payload.get("data") or {} text = ( "115 转存已完成\n" f"目录:{result.get('path') or final_path}\n" f"结果:{result.get('message') or 'success'}" if ok else self._format_115_error_text(message) ) return ok, text, { "action": "transfer_115", "ok": ok, "message": message or text, "result": self._compact_115_result(result), } return False, "暂不支持该分享链接类型,请发送夸克链接、115 链接或影巢片名。", { "action": "unknown_url", "ok": False, "message": "unsupported url", } if not keyword: return False, "未识别到可处理内容。你可以发送片名,或直接发送夸克/115 分享链接。", { "action": "empty", "ok": False, "message": "empty input", } final_path = target_path or self._get_hdhive_default_path() if self._should_use_agent_resource_officer(): ok, payload, message = self._call_aro_hdhive_session_search( keyword=keyword, media_type=media_type, year=year, target_path=final_path, ) result = payload.get("data") or {} candidates = result.get("candidates") or [] if not ok: return False, f"影巢搜索失败:{message or '暂无结果'}", { "action": "hdhive_candidates", "ok": False, "message": message or "session search failed", } session_id = str(result.get("session_id") or "").strip() if not candidates or not session_id: text = result.get("text") or f"影巢搜索失败:{message or '暂无结果'}" return False, text, { "action": "hdhive_candidates", "ok": False, "message": message or "empty candidates", } self._set_smart_cache( cache_key, action="aro_hdhive", items=[], target_path=final_path, keyword=keyword, meta={ "session_id": session_id, "stage": "candidate", "media_type": media_type, "year": year, "candidate_count": len(candidates), }, ) if len(candidates) == 1: pick_ok, pick_text, pick_data = self._execute_smart_pick("1", cache_key) return pick_ok, pick_text, pick_data text = str(result.get("text") or "").strip() or self._format_hdhive_candidate_text( keyword, [ { **dict(candidate or {}), "index": idx, } for idx, candidate in enumerate(candidates, start=1) ], final_path, page=1, page_size=self._hdhive_candidate_page_size, ) return True, text, { "action": "hdhive_candidates", "ok": True, "keyword": keyword, "path": final_path, "candidate_count": len(candidates), "next_action": "pick_candidate", "session_id": session_id, } candidate_page_size = 10 ok, payload, message = self._call_hdhive_search(keyword, media_type, year, candidate_limit=30, limit=20) result = payload.get("data") or {} items = result.get("data") or [] candidates = result.get("candidates") or [] if not ok or not items: text = f"影巢搜索失败:{message or result.get('message') or '暂无结果'}" if candidates and not items: text = ( f"已解析到 {len(candidates)} 个候选影片,但影巢暂无可用资源:{keyword}\n" "可以换个年份、片名别名,或稍后再试。" ) return False, text, { "action": "hdhive_search", "ok": False, "message": message or result.get("message") or text, "candidates": candidates, "items": [], } if len(candidates) > 1: cached_candidates = [] public_candidates = [] for index, candidate in enumerate(candidates, start=1): cached = dict(candidate) cached["index"] = index cached_candidates.append(cached) public_candidates.append( { "index": index, "tmdb_id": candidate.get("tmdb_id"), "title": candidate.get("title"), "year": candidate.get("year"), "media_type": candidate.get("media_type"), "actors": candidate.get("actors") or [], } ) self._set_smart_cache( cache_key, action="hdhive_candidates", items=cached_candidates, target_path=final_path, keyword=keyword, meta={ "media_type": media_type, "year": year, "page": 1, "page_size": candidate_page_size, }, ) text = self._format_hdhive_candidate_text( keyword, cached_candidates, final_path, page=1, page_size=candidate_page_size, ) return True, text, { "action": "hdhive_candidates", "ok": True, "keyword": keyword, "path": final_path, "candidates": public_candidates, "next_action": "pick_candidate", } cached_items = [] public_items = [] selected_candidate = candidates[0] if candidates else {} for item in self._collect_hdhive_channel_items(items, "115", 6) + self._collect_hdhive_channel_items(items, "quark", 6): cached = dict(item) cached["index"] = len(cached_items) + 1 cached_items.append(cached) if not cached_items: for item in items[:12]: cached = dict(item) cached["index"] = len(cached_items) + 1 cached_items.append(cached) for item in cached_items: cached = dict(item) public_items.append( { "index": cached.get("index"), "title": item.get("title"), "year": item.get("year"), "pan_type": item.get("pan_type"), "unlock_points": item.get("unlock_points"), "matched_title": item.get("matched_title"), "matched_year": item.get("matched_year"), } ) self._set_smart_cache( cache_key, action="hdhive_search", items=cached_items, target_path=final_path, keyword=keyword, meta={"media_type": media_type, "year": year, "candidate": selected_candidate}, ) text = self._format_hdhive_search_text(keyword, cached_items, selected_candidate, final_path) return True, text, { "action": "hdhive_search", "ok": True, "keyword": keyword, "path": final_path, "items": public_items, "candidate_count": len(candidates), "next_action": "pick", } def _execute_smart_pick( self, arg: str, cache_key: str, ) -> Tuple[bool, str, Dict[str, Any]]: index, override_path, pick_action = self._parse_pick_arg(arg) if self._should_use_agent_resource_officer(): if index <= 0 and not pick_action: return False, "请选择有效序号,例如:选择 1", { "action": "pick", "ok": False, "message": "invalid index", } ok, payload, message = self._call_aro_assistant_pick( cache_key, index, override_path or "", pick_action, ) data = payload.get("data") or {} text = str(message or "处理失败").strip() return ok, text, { "action": data.get("action") or "assistant_pick", "ok": ok, "message": text, "result": data, } cache = self._get_smart_cache(cache_key) if not cache: return False, "没有可继续的缓存,请先发送:处理 片名 或 处理 分享链接", { "action": "pick", "ok": False, "message": "cache not found", } cache_action = cache.get("action") if pick_action == "detail": if cache_action != "hdhive_candidates": return False, "当前结果不支持详情补充,请先发送影巢搜索。", { "action": "pick", "ok": False, "message": "detail unsupported", } items = cache.get("items") or [] if not items: return False, "当前没有可补充的候选影片。", { "action": "hdhive_candidates", "ok": False, "message": "empty candidates", } meta = dict(cache.get("meta") or {}) page_size = int(meta.get("page_size") or 10) current_page = int(meta.get("page") or 1) final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() start = max(0, (max(1, current_page) - 1) * max(1, page_size)) end = start + max(1, page_size) enriched_items = [dict(item or {}) for item in items] enriched_page_items = self._enrich_hdhive_candidates_with_actors( enriched_items[start:end], enabled=True, ) enriched_items[start:end] = enriched_page_items self._set_smart_cache( cache_key, action="hdhive_candidates", items=enriched_items, target_path=final_path, keyword=cache.get("keyword") or "", meta=meta, ) text = self._format_hdhive_candidate_text( cache.get("keyword") or "", enriched_items, final_path, page=current_page, page_size=page_size, ) return True, text, { "action": "hdhive_candidates", "ok": True, "keyword": cache.get("keyword") or "", "path": final_path, "page": current_page, "next_action": "pick_candidate", } if pick_action == "next_page": if cache_action != "hdhive_candidates": return False, "当前结果不支持翻页,请直接回复编号继续。", { "action": "pick", "ok": False, "message": "next page unsupported", } items = cache.get("items") or [] meta = dict(cache.get("meta") or {}) page_size = int(meta.get("page_size") or 10) total_pages = max(1, (len(items) + page_size - 1) // page_size) current_page = int(meta.get("page") or 1) if current_page >= total_pages: return False, "已经是最后一页了,可以直接回复编号继续选择。", { "action": "hdhive_candidates", "ok": False, "message": "already last page", } next_page = current_page + 1 final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() meta["page"] = next_page self._set_smart_cache( cache_key, action="hdhive_candidates", items=items, target_path=final_path, keyword=cache.get("keyword") or "", meta=meta, ) text = self._format_hdhive_candidate_text( cache.get("keyword") or "", items, final_path, page=next_page, page_size=page_size, ) return True, text, { "action": "hdhive_candidates", "ok": True, "keyword": cache.get("keyword") or "", "path": final_path, "page": next_page, "total_pages": total_pages, "next_action": "pick_candidate", } if index <= 0: return False, "请选择有效序号,例如:选择 1", { "action": "pick", "ok": False, "message": "invalid index", } items = cache.get("items") or [] if cache_action == "aro_hdhive": if pick_action in {"detail", "next_page"}: return False, "当前后端暂不支持详情补充或翻页,请直接回复编号继续。", { "action": "pick", "ok": False, "message": "unsupported action for aro session", } meta = cache.get("meta") or {} session_id = str(meta.get("session_id") or "").strip() final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() if not session_id: return False, "当前会话缺少 session_id,请重新发起影巢搜索。", { "action": "pick", "ok": False, "message": "session id missing", } ok, payload, message = self._call_aro_hdhive_session_pick( session_id=session_id, index=index, target_path=final_path, ) result = payload.get("data") or {} if not ok: return False, message or "资源处理失败", { "action": "aro_hdhive", "ok": False, "message": message or "session pick failed", } stage = str(result.get("stage") or "").strip() if stage == "resource": selected_candidate = dict(result.get("selected_candidate") or {}) resources = [dict(item or {}) for item in (result.get("resources") or [])] self._set_smart_cache( cache_key, action="aro_hdhive", items=[], target_path=final_path, keyword=cache.get("keyword") or "", meta={ **meta, "session_id": session_id, "stage": "resource", "candidate": selected_candidate, }, ) text = str(result.get("text") or "").strip() or self._format_hdhive_search_text( cache.get("keyword") or "", resources, selected_candidate, final_path, ) return True, text, { "action": "hdhive_search", "ok": True, "keyword": cache.get("keyword") or "", "path": final_path, "session_id": session_id, "next_action": "pick", } selected_resource = dict(result.get("selected_resource") or {}) route_result = dict(result.get("result") or {}) text = str(result.get("text") or "").strip() or self._format_aro_route_text( selected_resource, route_result, final_path, ) return True, text, { "action": "hdhive_unlock", "ok": True, "path": final_path, "session_id": session_id, "result": route_result, } if index > len(items): return False, f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。", { "action": "pick", "ok": False, "message": "index out of range", } selected = items[index - 1] if cache_action == "pansou_search": share_url = str(selected.get("url") or "").strip() access_code = str(selected.get("password") or "").strip() share_kind = self._detect_share_kind(share_url) final_path = override_path or ( self._get_hdhive_default_path() if share_kind == "115" else self._get_quark_default_path() if share_kind == "quark" else cache.get("target_path") or "" ) if share_kind == "115": ok, payload, message = self._call_hdhive_transfer_115( share_url, access_code, final_path, ) if not ok: return False, self._format_115_error_text(message), { "action": "transfer_115", "ok": False, "message": message or "transfer failed", } text = self._format_pansou_pick_text(selected, share_kind, payload, final_path) return True, text, { "action": "transfer_115", "ok": True, "path": final_path, "item": { "index": selected.get("index"), "title": selected.get("note"), "source": selected.get("source"), "channel": selected.get("channel"), }, "result": self._compact_115_result(payload.get("data") or {}), } if share_kind == "quark": ok, payload, message = self._call_quark_transfer( share_url, access_code, final_path, ) if not ok: return False, f"夸克转存失败:{message or '未知错误'}", { "action": "quark_transfer", "ok": False, "message": message or "transfer failed", } text = self._format_pansou_pick_text(selected, share_kind, payload, final_path) result = payload.get("data") or {} return True, text, { "action": "quark_transfer", "ok": True, "path": final_path, "item": { "index": selected.get("index"), "title": selected.get("note"), "source": selected.get("source"), "channel": selected.get("channel"), }, "result": { "target_path": result.get("target_path"), "task_id": result.get("task_id"), "saved_count": result.get("saved_count"), }, } return False, "当前盘搜结果不是 115 或夸克链接,暂不支持直接转存。", { "action": "pick", "ok": False, "message": "unsupported pansou result", } if cache_action == "hdhive_candidates": tmdb_id = selected.get("tmdb_id") if not tmdb_id: return False, "当前候选影片缺少 TMDB ID,无法继续查询资源。", { "action": "hdhive_candidates", "ok": False, "message": "tmdb_id missing", } meta = cache.get("meta") or {} final_path = override_path or cache.get("target_path") or self._get_hdhive_default_path() media_type = str(selected.get("media_type") or meta.get("media_type") or "movie").strip() year = str(selected.get("year") or meta.get("year") or "").strip() ok, payload, message = self._call_hdhive_search_by_tmdb(tmdb_id, media_type, year=year, limit=20) result = payload.get("data") or {} items = result.get("data") or [] if not items: candidate_label = self._format_hdhive_candidate_label(selected) hint = ( f"影巢当前暂无资源:{candidate_label}\n" "可以直接回复其他编号,继续查看别的候选影片。" ) if not ok: reason = message or result.get("message") or "暂无结果" hint = f"影巢搜索失败:{reason}\n{hint}" return False, hint, { "action": "hdhive_search", "ok": False, "message": message or result.get("message") or "no results", "candidate": { "index": selected.get("index"), "tmdb_id": tmdb_id, "title": selected.get("title"), "year": selected.get("year"), "media_type": selected.get("media_type"), }, } cached_items = [] for item in self._collect_hdhive_channel_items(items, "115", 6) + self._collect_hdhive_channel_items(items, "quark", 6): cached = dict(item) cached["index"] = len(cached_items) + 1 cached_items.append(cached) if not cached_items: for item in items[:12]: cached = dict(item) cached["index"] = len(cached_items) + 1 cached_items.append(cached) self._set_smart_cache( cache_key, action="hdhive_search", items=cached_items, target_path=final_path, keyword=cache.get("keyword") or "", meta={"media_type": media_type, "year": year, "candidate": selected}, ) text = self._format_hdhive_search_text(cache.get("keyword") or "", cached_items, selected, final_path) return True, text, { "action": "hdhive_search", "ok": True, "keyword": cache.get("keyword") or "", "path": final_path, "candidate": { "index": selected.get("index"), "tmdb_id": tmdb_id, "title": selected.get("title"), "year": selected.get("year"), "media_type": selected.get("media_type"), "actors": selected.get("actors") or [], }, "next_action": "pick", } if cache_action != "hdhive_search": return False, "当前缓存不支持按编号继续,请先发送影巢搜索或盘搜搜索。", { "action": "pick", "ok": False, "message": "unsupported cache action", } slug = str(selected.get("slug") or "").strip() if not slug: return False, "当前资源缺少 slug,无法继续解锁。", { "action": "pick", "ok": False, "message": "slug missing", } default_path = ( self._get_quark_default_path() if str(selected.get("pan_type") or "").strip().lower() == "quark" else self._get_hdhive_default_path() ) final_path = override_path or default_path ok, payload, message = self._call_hdhive_unlock( slug, transfer_115=True, target_path=final_path, ) if not ok: return False, f"影巢解锁失败:{message or '未知错误'}", { "action": "hdhive_unlock", "ok": False, "message": message or "unlock failed", } result = payload.get("data") or {} unlock_data = result.get("data") or {} share_url = str(unlock_data.get("full_url") or unlock_data.get("url") or "").strip() access_code = str(unlock_data.get("access_code") or "").strip() if self._detect_share_kind(share_url) == "quark": quark_ok, quark_payload, quark_message = self._call_quark_transfer( share_url, access_code, final_path, ) quark_result = quark_payload.get("data") or {} result["transfer_quark"] = { "ok": quark_ok, "target_path": quark_result.get("target_path") or final_path, "task_id": quark_result.get("task_id"), "saved_count": quark_result.get("saved_count"), "message": quark_message or quark_result.get("message"), } text = self._format_smart_pick_text(selected, payload, final_path) return True, text, { "action": "hdhive_unlock", "ok": True, "path": final_path, "item": { "index": selected.get("index"), "title": selected.get("title"), "year": selected.get("year"), "pan_type": selected.get("pan_type"), "unlock_points": selected.get("unlock_points"), }, "result": self._compact_unlock_result(payload.get("data") or {}), } def _execute_media_search(self, keyword: str, cache_key: str) -> str: try: meta = MetaInfo(keyword) mediainfo = MediaChain().recognize_media(meta=meta) if not mediainfo: return f"未识别到媒体信息:{keyword}" season = meta.begin_season if meta.begin_season else mediainfo.season results = SearchChain().search_by_id( tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, mtype=mediainfo.type, season=season, cache_local=False, ) or [] if not results: return f"已识别 {self._format_media_label(mediainfo, season)},但暂未搜索到资源。" self._set_search_cache(cache_key, keyword, mediainfo, results) lines = [ f"已识别:{self._format_media_label(mediainfo, season)}", f"共找到 {len(results)} 条资源,展示前 {min(len(results), 10)} 条:", ] for idx, context in enumerate(results[:10], start=1): torrent = context.torrent_info title = str(torrent.title or "").strip() size = StringUtils.str_filesize(torrent.size) if torrent.size else "未知" seeders = torrent.seeders if torrent.seeders is not None else "?" site = torrent.site_name or "未知站点" volume = torrent.volume_factor if getattr(torrent, "volume_factor", None) else "未知" lines.append(f"{idx}. [{site}] {title}") lines.append(f" 大小:{size} | 做种:{seeders} | 促销:{volume}") lines.append("下一步:回复“下载资源 序号”即可下载选中项。") lines.append("如需长期跟踪,回复“订阅媒体 片名”或“订阅并搜索 片名”。") return "\n".join(lines) except Exception as exc: logger.error( f"[FeishuCommandBridge] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}" ) return f"搜索资源失败:{keyword}\n错误:{exc}" def _execute_pansou_search(self, keyword: str, cache_key: str = "") -> str: ok, payload, message = self._call_pansou_search(keyword) if not ok: return f"盘搜搜索失败:{keyword}\n错误:{message}" data = payload.get("data") or {} merged = data.get("merged_by_type") or {} def normalize_channel_name(channel: str) -> str: text = str(channel or "").strip().lower() if text == "115" or "115" in text: return "115" if "quark" in text: return "quark" return str(channel or "").strip() or "未知" def collect_channel_items(channel_name: str, limit: int) -> List[Dict[str, Any]]: raw_items = merged.get(channel_name) or [] if not isinstance(raw_items, list): return [] results: List[Dict[str, Any]] = [] seen = set() for item in raw_items: if not isinstance(item, dict): continue url = str(item.get("url") or "").strip() if not url: continue note = str(item.get("note") or "未命名资源").strip() password = str(item.get("password") or "").strip() source = str(item.get("source") or "").strip() dt = self._format_pansou_datetime(item.get("datetime")) key = (url, note) if key in seen: continue seen.add(key) results.append( { "channel": normalize_channel_name(channel_name), "url": url, "password": password, "note": note, "source": source, "datetime": dt, } ) if len(results) >= limit: break return results channel_115 = collect_channel_items("115", 6) channel_quark = collect_channel_items("quark", 6) cached_items: List[Dict[str, Any]] = [] for item in channel_115: cached_items.append({**item, "index": len(cached_items) + 1}) for item in channel_quark: cached_items.append({**item, "index": len(cached_items) + 1}) if not cached_items: return f"盘搜暂无结果:{keyword}" total = int(data.get("total") or (len(channel_115) + len(channel_quark))) if cache_key and cached_items: self._set_smart_cache( cache_key, action="pansou_search", keyword=keyword, target_path=self._get_hdhive_default_path(), items=cached_items, ) lines = [ f"盘搜搜索:{keyword}", ( f"共找到 {total} 条结果,当前展示 115 {len(channel_115)} 条" f"、夸克 {len(channel_quark)} 条:" ), ] for idx, cached in enumerate(cached_items): idx = cached["index"] channel = cached["channel"] note = cached["note"] url = cached["url"] password = cached["password"] source = cached["source"] dt = cached.get("datetime") or "" if idx == 1: lines.append("🟦 115 结果") elif channel == "quark" and idx == len(channel_115) + 1: lines.append("🟨 夸克结果") title_line = f"{idx}. [{channel}] {note}" lines.append(title_line) detail_parts = [] if source: detail_parts.append(source) if dt: detail_parts.append(dt) if detail_parts: lines.append(f" {' · '.join(detail_parts)}") if password: lines.append(f" 提取码:{password}") lines.append(f" {url}") lines.append("下一步:回复“选择 1”即可直接转存支持的 115 / 夸克结果。") if channel_quark: start_index = len(channel_115) + 1 lines.append(f"夸克结果从 {start_index} 开始编号;例如“选择 {start_index}”可直接处理第 1 条夸克结果。") next_quark_hint = len(channel_115) + 1 if channel_quark else 1 lines.append(f"如需改目录,可发“选择 1 path=/目录”或“选择 {next_quark_hint} path=/目录”。") return "\n".join(lines) def _execute_media_download(self, index: int, cache_key: str) -> str: cache = self._get_search_cache(cache_key) if not cache: return "没有可用的搜索缓存,请先发送:搜索资源 片名" results = cache.get("results") or [] if index < 1 or index > len(results): return f"序号超出范围,请输入 1 到 {len(results)} 之间的数字。" context = copy.deepcopy(results[index - 1]) torrent = context.torrent_info try: download_id = DownloadChain().download_single( context=context, username="feishucommandbridgelong", source="FeishuCommandBridgeLong", ) if not download_id: return f"下载提交失败:{torrent.title}" return ( f"已提交下载:{torrent.title}\n" f"站点:{torrent.site_name or '未知站点'}\n" f"任务ID:{download_id}" ) except Exception as exc: logger.error( f"[FeishuCommandBridge] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}" ) return f"下载资源失败:{torrent.title}\n错误:{exc}" def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: meta = MetaInfo(keyword) season = meta.begin_season try: sid, message = SubscribeChain().add( title=keyword, year=meta.year, mtype=meta.type, season=season, username="feishucommandbridgelong", exist_ok=True, message=False, ) if not sid: return f"订阅失败:{keyword}\n原因:{message}" lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] if immediate_search: Scheduler().start( job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}, ) lines.append("已触发一次订阅搜索。") return "\n".join(lines) except Exception as exc: logger.error( f"[FeishuCommandBridge] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}" ) return f"订阅失败:{keyword}\n错误:{exc}" def _run_quark_save( self, arg: str, receive_chat_id: str, receive_open_id: str, ) -> None: summary = self._execute_quark_save(arg) self._reply_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, text=summary, ) @staticmethod def _parse_quark_save_arg(arg: str) -> Tuple[str, str, str]: text = str(arg or "").strip() url_match = re.search(r"https?://[^\s<>\"']+", text) share_url = url_match.group(0).rstrip(".,);]") if url_match else "" access_code = "" target_path = "" remain = text.replace(share_url, " ").strip() if share_url else text for token in remain.split(): item = token.strip() if not item: continue if "=" in item: key, value = item.split("=", 1) key = key.strip().lower() value = value.strip() if key in {"pwd", "passcode", "code", "提取码"} and value: access_code = value continue if key in {"path", "dir", "目录", "位置"} and value: target_path = value continue if item.startswith("/") and not target_path: target_path = item continue if not access_code and len(item) <= 8: access_code = item return share_url, access_code, FeishuCommandBridgeLong._resolve_pan_path_value(target_path) def _execute_quark_save(self, arg: str) -> str: share_url, access_code, target_path = self._parse_quark_save_arg(arg) if not share_url: return ( "夸克转存失败:未识别到分享链接\n" "用法:夸克转存 分享链接 pwd=提取码 path=/保存目录" ) ok, payload, message = self._call_quark_transfer( share_url=share_url, access_code=access_code, target_path=target_path or self._get_quark_default_path(), ) if not ok: return f"夸克转存失败:{message or '未知错误'}" result = payload.get("data") or {} return "\n".join( [ "夸克转存已完成", f"目录:{result.get('target_path') or target_path or self._get_quark_default_path() or '-'}", ] ) @staticmethod def _format_media_label(mediainfo: Any, season: Optional[int] = None) -> str: title = getattr(mediainfo, "title", "") or "未知媒体" year = getattr(mediainfo, "year", None) label = f"{title} ({year})" if year else title media_type = getattr(mediainfo, "type", None) media_type_name = getattr(media_type, "name", "") if media_type_name == "TV" and season: return f"{label} 第{season}季" return label def _extract_text(self, content: Any) -> str: if isinstance(content, dict): return str(content.get("text") or "").strip() if isinstance(content, str): try: payload = json.loads(content) except json.JSONDecodeError: return content.strip() return str(payload.get("text") or "").strip() return "" @staticmethod def _sanitize_text(text: str) -> str: text = re.sub(r"]*>.*?", " ", text or "", flags=re.IGNORECASE) text = re.sub(r"\s+", " ", text).strip() return text @staticmethod def _split_lines(value: Any) -> List[str]: return [line.strip() for line in str(value or "").splitlines() if line.strip()] @staticmethod def _split_commands(value: Any) -> List[str]: raw = str(value or "").replace("\n", ",") return [item.strip() for item in raw.split(",") if item.strip()] @staticmethod def _mask_secret(value: str) -> str: value = str(value or "").strip() if not value: return "" if len(value) <= 8: return "*" * len(value) return f"{value[:4]}...{value[-4:]}" def _reply_if_needed( self, receive_chat_id: str, receive_open_id: str, text: str, ) -> None: if not self._reply_enabled: return if not self._app_id or not self._app_secret: return receive_id_type = self._reply_receive_id_type receive_id = receive_chat_id if receive_id_type == "chat_id" else receive_open_id if not receive_id: return access_token = self._get_tenant_access_token() if not access_token: return url = ( "https://open.feishu.cn/open-apis/im/v1/messages" f"?receive_id_type={receive_id_type}" ) headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json; charset=utf-8", } payload = { "receive_id": receive_id, "msg_type": "text", "content": json.dumps({"text": text}, ensure_ascii=False), } logger.info(f"[FeishuCommandBridge] 准备回复飞书:{text}") response = RequestUtils(headers=headers).post(url=url, json=payload) if response is None: logger.error("[FeishuCommandBridge] failed to send reply to Feishu") return try: data = response.json() except Exception: data = {} if response.status_code != 200 or data.get("code") not in (0, None): logger.error( f"[FeishuCommandBridge] reply failed: " f"status={response.status_code} body={data}" ) def _upload_image_to_feishu(self, image_bytes: bytes, file_name: str = "qrcode.png") -> Optional[str]: if not image_bytes or not self._app_id or not self._app_secret: return None access_token = self._get_tenant_access_token() if not access_token: return None headers = {"Authorization": f"Bearer {access_token}"} response = RequestUtils(headers=headers).post( url="https://open.feishu.cn/open-apis/im/v1/images", data={"image_type": "message"}, files={"image": (file_name, image_bytes, "image/png")}, ) if response is None: logger.error("[FeishuCommandBridge] 上传飞书图片失败:无响应") return None try: data = response.json() except Exception: data = {} if response.status_code != 200 or data.get("code") not in (0, None): logger.error( f"[FeishuCommandBridge] 上传飞书图片失败: status={response.status_code} body={data}" ) return None return str(((data.get("data") or {}).get("image_key")) or "").strip() or None def _reply_image_if_needed( self, receive_chat_id: str, receive_open_id: str, image_key: str, ) -> None: if not image_key or not self._reply_enabled or not self._app_id or not self._app_secret: return receive_id_type = self._reply_receive_id_type receive_id = receive_chat_id if receive_id_type == "chat_id" else receive_open_id if not receive_id: return access_token = self._get_tenant_access_token() if not access_token: return url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}" headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json; charset=utf-8", } payload = { "receive_id": receive_id, "msg_type": "image", "content": json.dumps({"image_key": image_key}, ensure_ascii=False), } response = RequestUtils(headers=headers).post(url=url, json=payload) if response is None: logger.error("[FeishuCommandBridge] 发送飞书图片失败:无响应") return try: data = response.json() except Exception: data = {} if response.status_code != 200 or data.get("code") not in (0, None): logger.error( f"[FeishuCommandBridge] 发送飞书图片失败: status={response.status_code} body={data}" ) def _reply_qrcode_data_url_if_needed( self, receive_chat_id: str, receive_open_id: str, data_url: str, ) -> None: text = str(data_url or "").strip() if not text.startswith("data:image/") or ";base64," not in text: return _, _, payload = text.partition(";base64,") try: image_bytes = b64decode(payload) except Exception as exc: logger.error(f"[FeishuCommandBridge] 解码二维码图片失败:{exc}") return image_key = self._upload_image_to_feishu(image_bytes=image_bytes, file_name="p115-qrcode.png") if image_key: self._reply_image_if_needed( receive_chat_id=receive_chat_id, receive_open_id=receive_open_id, image_key=image_key, ) def _get_tenant_access_token(self) -> Optional[str]: now = time.time() with self._token_lock: token = self._token_cache.get("token") expires_at = float(self._token_cache.get("expires_at") or 0) if token and now < expires_at - 60: return token response = RequestUtils(content_type="application/json").post( url="https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/", json={"app_id": self._app_id, "app_secret": self._app_secret}, ) if response is None: logger.error("[FeishuCommandBridge] failed to fetch tenant access token") return None try: data = response.json() except Exception as exc: logger.error( f"[FeishuCommandBridge] invalid token response from Feishu: {exc}" ) return None token = data.get("tenant_access_token") expire = int(data.get("expire") or 0) if not token: logger.error( f"[FeishuCommandBridge] token missing in response: {data}" ) return None self._token_cache = {"token": token, "expires_at": now + expire} return token